diff --git a/CHANGELOG.md b/CHANGELOG.md index 968384f..31a07b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +BUG FIXES: + +- `forgejo_repository_webhook`: Preserve write-only `config.secret` from configuration so managing the webhook secret no longer causes "Provider produced inconsistent result after apply" + ## 1.5.2 (August 2, 2026) ENHANCEMENTS: diff --git a/README.md b/README.md index ade68f8..d55acff 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Tests](https://github.com/svalabs/terraform-provider-forgejo/actions/workflows/test.yml/badge.svg) ![Release](https://github.com/svalabs/terraform-provider-forgejo/actions/workflows/release.yml/badge.svg) -![Test Coverage](https://img.shields.io/badge/Coverage-74.7%25-brightgreen?logo=go) +![Test Coverage](https://img.shields.io/badge/Coverage-74.9%25-brightgreen?logo=go) ![Version](https://img.shields.io/github/v/release/svalabs/terraform-provider-forgejo?logo=terraform&label=Version) ![License](https://img.shields.io/github/license/svalabs/terraform-provider-forgejo?logo=github&label=License) diff --git a/docs/resources/repository_webhook.md b/docs/resources/repository_webhook.md index 222d432..563430f 100644 --- a/docs/resources/repository_webhook.md +++ b/docs/resources/repository_webhook.md @@ -40,6 +40,8 @@ resource "forgejo_repository_webhook" "example" { config = { "content_type" = "json" "url" = "http://example.com/invoke" + # The "secret" key is write-only: never read back from the API. + "secret" = "supersecret" } } @@ -56,7 +58,7 @@ import { ### Required -- `config` (Map of String) Map of configuration settings. +- `config` (Map of String) Map of configuration settings, e.g. "content_type" and "url". The "secret" key is write-only: Forgejo accepts it on create/update but never returns it, so the provider preserves the configured value instead of reading it back, and cannot detect changes made outside of Terraform. - `repository_id` (Number) Numeric identifier of the repository. Changing this forces a new resource to be created. - `type` (String) Type of webhook. Changing this forces a new resource to be created. diff --git a/examples/resources/forgejo_repository_webhook/resource.tf b/examples/resources/forgejo_repository_webhook/resource.tf index efd0e3f..5761bf6 100644 --- a/examples/resources/forgejo_repository_webhook/resource.tf +++ b/examples/resources/forgejo_repository_webhook/resource.tf @@ -25,6 +25,8 @@ resource "forgejo_repository_webhook" "example" { config = { "content_type" = "json" "url" = "http://example.com/invoke" + # The "secret" key is write-only: never read back from the API. + "secret" = "supersecret" } } diff --git a/internal/provider/repository_webhook_resource.go b/internal/provider/repository_webhook_resource.go index bd3cceb..0f4bc60 100644 --- a/internal/provider/repository_webhook_resource.go +++ b/internal/provider/repository_webhook_resource.go @@ -53,6 +53,28 @@ type repositoryWebhookResourceModel struct { UpdatedAt types.String `tfsdk:"updated_at"` } +// repositoryWebhookWriteOnlyConfigKeys lists keys within the webhook "config" +// map that the Forgejo API accepts on create/update but never returns in +// responses (a GET on the hook only echoes back e.g. "url" and +// "content_type"). +var repositoryWebhookWriteOnlyConfigKeys = []string{"secret"} + +// redactRepositoryWebhookConfig returns a copy of a webhook "config" map with +// the values of all write-only keys obfuscated, for safe use in log output. +func redactRepositoryWebhookConfig(config map[string]string) map[string]string { + redacted := make(map[string]string, len(config)) + for k, v := range config { + redacted[k] = v + } + for _, key := range repositoryWebhookWriteOnlyConfigKeys { + if v, ok := redacted[key]; ok { + redacted[key] = strings.Repeat("*", len(v)) + } + } + + return redacted +} + // from is a helper function to load an API struct into Terraform data model. func (m *repositoryWebhookResourceModel) from(h *forgejo.Hook, ctx context.Context) (diags diag.Diagnostics) { if h == nil { @@ -61,9 +83,29 @@ func (m *repositoryWebhookResourceModel) from(h *forgejo.Hook, ctx context.Conte var d diag.Diagnostics + // The API response never includes write-only config keys (e.g. "secret"). + // Strip any value it might echo back (e.g. a masked placeholder) and + // restore the value from the prior model (the plan on create/update, the + // prior state on read), so the applied config matches what was planned. + config := make(map[string]string, len(h.Config)) + for k, v := range h.Config { + config[k] = v + } + var priorConfig map[string]string + if !m.Config.IsNull() && !m.Config.IsUnknown() { + d = m.Config.ElementsAs(ctx, &priorConfig, false) + diags.Append(d...) + } + for _, key := range repositoryWebhookWriteOnlyConfigKeys { + delete(config, key) + if v, ok := priorConfig[key]; ok { + config[key] = v + } + } + m.WebhookID = types.Int64Value(h.ID) m.Active = types.BoolValue(h.Active) - m.Config, d = types.MapValueFrom(ctx, types.StringType, h.Config) + m.Config, d = types.MapValueFrom(ctx, types.StringType, config) diags.Append(d...) m.CreatedAt = types.StringValue(h.Created.Format(time.RFC3339)) m.Events, d = types.SetValueFrom(ctx, types.StringType, h.Events) @@ -140,7 +182,7 @@ func (r *repositoryWebhookResource) Schema(_ context.Context, _ resource.SchemaR Default: stringdefault.StaticString(""), }, "config": schema.MapAttribute{ - Description: "Map of configuration settings.", + Description: "Map of configuration settings, e.g. \"content_type\" and \"url\". The \"secret\" key is write-only: Forgejo accepts it on create/update but never returns it, so the provider preserves the configured value instead of reading it back, and cannot detect changes made outside of Terraform.", ElementType: types.StringType, Required: true, }, @@ -295,7 +337,7 @@ func (r *repositoryWebhookResource) Create(ctx context.Context, req resource.Cre "active": data.Active.ValueBool(), "authorization_header": strings.Repeat("*", len(data.AuthorizationHeader.ValueString())), "branch_filter": data.BranchFilter.ValueString(), - "config": config, + "config": redactRepositoryWebhookConfig(config), "events": events, "repo": repo.Name.ValueString(), "type": data.Type.ValueString(), @@ -501,7 +543,7 @@ func (r *repositoryWebhookResource) Update(ctx context.Context, req resource.Upd "active": data.Active.ValueBool(), "authorization_header": strings.Repeat("*", len(data.AuthorizationHeader.ValueString())), "branch_filter": data.BranchFilter.ValueString(), - "config": config, + "config": redactRepositoryWebhookConfig(config), "events": events, "owner": repo.Owner.ValueString(), "repo": repo.Name.ValueString(), diff --git a/internal/provider/repository_webhook_resource_test.go b/internal/provider/repository_webhook_resource_test.go index 9282dc0..75d863c 100644 --- a/internal/provider/repository_webhook_resource_test.go +++ b/internal/provider/repository_webhook_resource_test.go @@ -45,6 +45,7 @@ resource "forgejo_repository_webhook" "test" { config = { "content_type" = "json" "url" = "http://example.com/abc12345" + "secret" = "supersecret" } }`, ConfigPlanChecks: resource.ConfigPlanChecks{ @@ -62,8 +63,11 @@ resource "forgejo_repository_webhook" "test" { statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("authorization_header"), knownvalue.Null()), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("type"), knownvalue.StringExact("forgejo")), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("events").AtSliceIndex(0), knownvalue.StringExact("push")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("content_type"), knownvalue.StringExact("json")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("url"), knownvalue.StringExact("http://example.com/abc12345")), + statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config"), knownvalue.MapExact(map[string]knownvalue.Check{ + "content_type": knownvalue.StringExact("json"), + "url": knownvalue.StringExact("http://example.com/abc12345"), + "secret": knownvalue.StringExact("supersecret"), + })), }, }, // Import testing (invalid identifier) @@ -104,6 +108,7 @@ found`), ImportStateIdPrefix: "tfadmin/test_repo/", ImportStateVerify: true, ImportStateVerifyIdentifierAttribute: "webhook_id", + ImportStateVerifyIgnore: []string{"config.secret", "config.%"}, }, // Update and Read testing { @@ -118,6 +123,7 @@ resource "forgejo_repository_webhook" "test" { config = { "content_type" = "json" "url" = "http://example.com/abc12345" + "secret" = "rotatedsecret" } active = true authorization_header = "Bearer token123456" @@ -138,8 +144,11 @@ resource "forgejo_repository_webhook" "test" { statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("authorization_header"), knownvalue.StringExact("Bearer token123456")), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("type"), knownvalue.StringExact("forgejo")), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("events").AtSliceIndex(0), knownvalue.StringExact("push")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("content_type"), knownvalue.StringExact("json")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("url"), knownvalue.StringExact("http://example.com/abc12345")), + statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config"), knownvalue.MapExact(map[string]knownvalue.Check{ + "content_type": knownvalue.StringExact("json"), + "url": knownvalue.StringExact("http://example.com/abc12345"), + "secret": knownvalue.StringExact("rotatedsecret"), + })), }, }, // Recreate and Read testing @@ -175,8 +184,10 @@ resource "forgejo_repository_webhook" "test" { statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("authorization_header"), knownvalue.StringExact("Bearer token123456")), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("type"), knownvalue.StringExact("gitea")), statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("events").AtSliceIndex(0), knownvalue.StringExact("push")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("content_type"), knownvalue.StringExact("json")), - statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config").AtMapKey("url"), knownvalue.StringExact("http://example.com/abc12345")), + statecheck.ExpectKnownValue("forgejo_repository_webhook.test", tfjsonpath.New("config"), knownvalue.MapExact(map[string]knownvalue.Check{ + "content_type": knownvalue.StringExact("json"), + "url": knownvalue.StringExact("http://example.com/abc12345"), + })), }, }, },