Skip to content

fix: config merging in source/destination updates#211

Open
leggetter wants to merge 11 commits into
mainfrom
claude/fix-terraform-rate-limits-aXPfL
Open

fix: config merging in source/destination updates#211
leggetter wants to merge 11 commits into
mainfrom
claude/fix-terraform-rate-limits-aXPfL

Conversation

@leggetter
Copy link
Copy Markdown
Collaborator

Summary

This PR fixes an issue where removed configuration keys were not being properly deleted when updating sources and destinations. The Hookdeck API merges configuration updates, so omitted keys would persist on the resource even after being removed from the Terraform configuration.

Key Changes

  • Modified Update methods in both destination/sdk.go and source/sdk.go to accept the prior configuration state as a parameter
  • Added toUpdatePayload methods to both destination and source models that:
    • Build the update payload using the existing toPayload() method
    • Parse the prior configuration state
    • Explicitly set removed config keys to null in the update payload to ensure they are deleted on the API side
  • Updated resource update handlers in destination/resource.go and source/resource.go to:
    • Retrieve the current state before calling Update()
    • Pass the prior configuration (as a JSON string) to the Update() method

Implementation Details

The solution works by comparing the prior configuration keys with the new configuration keys. Any keys that existed before but are absent in the new configuration are explicitly set to null in the update payload. This ensures the Hookdeck API removes those configuration options rather than retaining them due to its merge behavior.

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz

claude added 6 commits May 20, 2026 12:11
The Hookdeck API performs a partial (merge) update on the destination
and source `config` object: keys omitted from the request body retain
their existing values. Removing fields like `rate_limit` /
`rate_limit_period` from a Terraform config therefore left them set on
the resource and required manual cleanup in the dashboard.

Diff the prior state's config against the new plan and explicitly send
`null` for any top-level keys that were removed, so the API clears them.

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Verifies that removing rate_limit and rate_limit_period from a
destination's config actually clears them on the Hookdeck API.

Because the config attribute is not Computed (Terraform state stores the
user's input string, not the API's actual config), the assertion calls
GET /destinations/{id} directly and inspects the returned config object.

Run with:
  TEST=./internal/provider/destination \
  RUN=TestAccDestinationResource_RemoveRateLimit \
  make testacc

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
The Hookdeck API resets rate_limit_period to its default ("second") when
rate_limit is cleared, rather than nulling it. The user-facing fix is
proven by rate_limit itself being null — rate limiting is disabled
regardless of what rate_limit_period reads.

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Four new acceptance tests for destination config key removal:

- AddRemoveReaddRateLimit: exercises the diff logic across multiple
  Update cycles (add, remove, remove, re-add) to catch state issues.
- NoDriftAfterRateLimitRemoval: re-applies the same config after
  removal and asserts an empty plan, guarding against the null-out
  baseline drifting between applies.
- RemoveHTTPMethod: proves the fix generalizes beyond rate_limit.
  http_method documented to default to null, so removal should produce
  a true null on the API (unlike rate_limit_period, which has a
  non-null default of "second").
- RateLimitPeriodMergeBehavior: investigates whether the API
  auto-resets rate_limit_period whenever rate_limit becomes null, or
  only when we explicitly send null for the period field. Sends null
  for rate_limit while keeping period in the config; assertion failure
  would indicate API-side auto-reset behavior, not a provider bug.

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
The previous test job targeted ./internal/provider/ (non-recursive),
which has no _test.go files — so it passed by doing nothing. The
existing acceptance tests in connection/ and transformation/, and the
new destination/ tests, weren't actually running in CI.

Changes:
- Recurse into ./internal/provider/...
- Inject HOOKDECK_API_KEY from repo secrets so TF_ACC=1 tests can run.
- Skip the test job on fork PRs (where secrets aren't available); they
  re-run after merge.
- Limit push trigger to main to avoid duplicate runs on PR branches.
- Refresh TF version matrix from 1.0-1.4 (unsupported) to 1.9 and 1.10.
- Serialize acceptance runs (job concurrency + matrix max-parallel: 1)
  so the 240 req/min Hookdeck API limit isn't exceeded across parallel
  runs — the provider doesn't retry on 429.
- Bump timeouts from 15/10m to 30/25m for headroom as the suite grows.

Requires HOOKDECK_API_KEY to be set as a repo secret pointing at a
dedicated test workspace (not production).

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Go modules (bundles #197, #201, #202, #203, #204):
- github.com/hashicorp/terraform-plugin-testing 1.13.3 → 1.14.0
- github.com/hashicorp/terraform-plugin-framework 1.17.0 → 1.19.0
- github.com/hashicorp/terraform-plugin-go 0.29.0 → 0.31.0
- golang.org/x/sys 0.37.0 → 0.43.0 (>= #201's 0.42.0; supersedes #198)
- golang.org/x/time 0.14.0 → 0.15.0

Transitive cascade required by plugin-go 0.31.0 (which makes
GenerateResourceConfig required on tfprotov5/6.ResourceServer):
- github.com/hashicorp/terraform-plugin-sdk/v2 2.37.0 → 2.40.1
- Go toolchain 1.24 → 1.25.8

GitHub Actions (bundles #206, #207, #208, #209, #210):
- hashicorp/setup-terraform 3 → 4
- golangci/golangci-lint-action 8 → 9
- crazy-max/ghaction-import-gpg 6.2.0 → 7.0.0
- actions/setup-go 5 → 6
- goreleaser/goreleaser-action 6 → 7

CI guard:
- Skip the acceptance test job for actor dependabot[bot]. Dependabot
  runs with a restricted GITHUB_TOKEN and can't access repo secrets, so
  HOOKDECK_API_KEY would be empty and the tests would t.Fatal. Tests
  re-run on push to main once dep PRs are merged.

#198 superseded by #201 (same library, lower version).

https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
@leggetter leggetter changed the title Fix config merging in source/destination updates fix: config merging in source/destination updates May 20, 2026
golangci-lint v2.1.6 was built with Go 1.24 and refuses to lint code
targeting Go 1.25+ ("the Go language version used to build golangci-lint
is lower than the targeted Go version"). The go.mod bump to 1.25.8 in
the prior dependabot consolidation tripped this. v2.5.0 is built with
Go 1.25 and is what's been verified locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes Hookdeck API “merge” semantics during Terraform updates by explicitly nulling removed config keys so they are deleted server-side, and adds acceptance coverage for destination config key removals.

Changes:

  • Update source/destination update flows to pass prior config JSON from state and build update payloads that set removed keys to null
  • Add destination acceptance tests and HCL fixtures exercising key removal / drift behavior
  • Bump Go / Terraform plugin dependencies and modernize CI workflows (Go setup, lint, acceptance job gating/serialization)

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
internal/provider/source/sdk.go Build update payload using prior config to null removed keys
internal/provider/source/resource.go Fetch prior state config and pass into Update
internal/provider/destination/sdk.go Same null-out update payload logic for destinations
internal/provider/destination/resource.go Fetch prior state config and pass into Update
internal/provider/destination/resource_test.go New destination acceptance tests validating API-side key deletion
internal/provider/destination/testdata/*.tf Test fixtures for destination config variations
go.mod Go version + dependency updates
go.sum Dependency checksum updates
.github/workflows/test.yml CI updates + acceptance test gating/serialization
.github/workflows/release.yml Workflow action version bumps

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +105 to +111
var state *sourceResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

diags := data.Update(ctx, &r.client, state.Config.ValueString())
Comment on lines +105 to +111
var state *destinationResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

diags := data.Update(ctx, &r.client, state.Config.ValueString())
Comment thread go.mod
go 1.24.0

toolchain go1.24.7
go 1.25.8
Comment on lines +285 to +290
var priorPayloadConfig map[string]interface{}
if err := json.Unmarshal([]byte(priorConfig), &priorPayloadConfig); err != nil {
diags.AddError("Error parsing prior source config", err.Error())
return nil, diags
}

Comment on lines +278 to +283
var priorPayloadConfig map[string]interface{}
if err := json.Unmarshal([]byte(priorConfig), &priorPayloadConfig); err != nil {
diags.AddError("Error parsing prior destination config", err.Error())
return nil, diags
}

Comment on lines +275 to +283
func (m *sourceResourceModel) toUpdatePayload(priorConfig string) (map[string]interface{}, diag.Diagnostics) {
payload, diags := m.toPayload()
if diags.HasError() {
return nil, diags
}

if priorConfig == "" {
return payload, diags
}
Comment on lines +32 to +36
func loadTestConfigFormatted(filename string, args ...interface{}) string {
path := filepath.Join("testdata", filename)
content, err := os.ReadFile(path)
if err != nil {
panic(err)
leggetter and others added 4 commits May 20, 2026 19:51
Mirrors TestAccDestinationResource_RemoveRateLimit for sources: applies
an HTTP source with allowed_http_methods set, removes it, and verifies
via direct API read that the key is cleared. Exercises the same null-out
fix in source/sdk.go that resource_test.go covers for destinations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Hookdeck OpenAPI spec marks every array- and boolean-typed top-level
config property as non-nullable on both source and destination. Sending
null for those fields produces a 422 "must be an array" / type error,
turning the original silent merge bug into an apply-time failure.

Type-switch on the prior value's runtime type and skip arrays and
booleans in the diff loop. Scalars (string/number) and nullable objects
continue to be cleared via null, which is what the destination test
suite already verifies. For non-nullable types we fall back to the
original merge behavior (key omitted from the payload); the provider
can't reset such fields to their API default, but the apply succeeds
rather than erroring.

Tests:
- Replace the source `allowed_http_methods` test (was exercising the
  bug introduced by the previous commit) with a `custom_response` test
  using a nullable object — actually exercises the fix on the source
  side, mirroring how destination tests exercise it on nullable scalars.
- Add a regression test that removing `allowed_http_methods` succeeds
  without 422, locking in the array-skip behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, removing a non-nullable config field (e.g. array
`allowed_http_methods`, boolean `path_forwarding_disabled`) from a
Terraform config left a half-fixed state: sending null returned 422 and
broke the apply, while skipping the field left the original merge bug
in place. Neither honors the user's intent of "no longer manage this
field".

Add a small per-type map of known documented defaults for non-nullable
fields (audited against the OpenAPI spec at api.hookdeck.com/2025-07-01):
- HTTP source `allowed_http_methods` → ["PUT","POST","PATCH","DELETE"]
  (spec description documents this exact default)
- HTTP / CLI / MOCK_API destination `path_forwarding_disabled` → false
  (response examples + docs: "By default, Hookdeck preserves any path
  appended to the Hookdeck URL")

When a known non-nullable field is removed, send the documented default
explicitly so the API resets the field rather than retaining it. For
non-nullable fields without a known default (none today, but possible
for new source/destination types), fall back to omitting — apply still
succeeds, but the merge bug remains for that field until we add it to
the map.

Tests:
- Replace the source `DoesNotError` regression test with
  `ResetsToDefault`, asserting the API now has the documented default
  array after removal.
- Add a parallel destination test for `path_forwarding_disabled`
  removal, asserting the API resets to false.

Test rate-limit handling: in TF_ACC=1 mode, share a single rate limiter
across every RawClient created in the process. Each acceptance step
spins up a fresh provider, otherwise each gets its own 5-token burst,
and stacked bursts across a large test suite were tripping the API's
240 req/min sliding window (visible as a 429 on the connection test
after destinations + sources had run). Production behavior is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared rate limiter prevents the 5-token-burst-per-provider stacking
issue within a single test process, but cross-process API budget can
still occasionally trip the API's 240 req/min sliding window: each
go-test-binary boundary resets the local limiter, and cumulative
requests across the suite can still 429 even when each binary's own
rate is conservative.

Wrap the HTTP client under TF_ACC=1 to retry 429 responses with the
Retry-After header respected (falling back to exponential backoff:
2s, 4s, 8s, 16s, 32s, capped at 60s, max 5 retries). Production
behavior is unchanged — the sdkclient's existing comment explicitly
rejects retry-on-429 there to avoid masking systemic issues and
consuming rate-limit tokens; the test wrapper exists only to absorb
transient bursts driven by the test framework's provider lifecycle.

Includes a unit-test scripted-HTTPDoer covering: success-first-attempt,
retry-then-success, max-retries-exhausted (returns final 429),
exponential backoff when Retry-After absent, non-429 passthrough,
transport-error passthrough, 60s wait cap, and HTTP-date Retry-After
parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants