fix: config merging in source/destination updates#211
Conversation
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
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>
There was a problem hiding this comment.
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
configJSON from state and build update payloads that set removed keys tonull - 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.
| 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 | ||
| } |
There was a problem hiding this comment.
Source acceptance coverage was added in 1a17fb7: TestAccSourceResource_RemoveCustomResponse (removes a nullable object and asserts the key is absent via API read) and TestAccSourceResource_RemoveAllowedHTTPMethodsResetsToDefault (non-nullable array resets to the documented default). Both read back from the API.
Generated by Claude Code
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>
alexluong
left a comment
There was a problem hiding this comment.
LGTM, left a few comments! Appreciate the testing setup as well
| var nonNullableConfigDefaults = map[string]map[string]interface{}{ | ||
| "HTTP": { | ||
| "allowed_http_methods": []interface{}{"PUT", "POST", "PATCH", "DELETE"}, | ||
| }, | ||
| } |
There was a problem hiding this comment.
This feels a bit off because it requires us maintaining this mapping. Right now we have many sources but this only applies for HTTP. What about other cases?
That said, I don't have a suggestion here yet, just want to flag this and see if there's something else we can consider?
There was a problem hiding this comment.
Agreed it's not ideal. The API marks array/boolean config fields non-nullable, so null returns 422 — we can't clear them like nullable fields, so the map resets them to their documented defaults (audited against the 2025-07-01 spec; rationale in 82234fc). You're right it's a maintained mapping, and unmapped fields fall back to merge. The clean fix is API-side — accept null and reset server-side — which removes the map. I'll raise it with the team.
| for key, priorValue := range priorPayloadConfig { | ||
| if _, exists := newPayloadConfig[key]; exists { | ||
| continue | ||
| } |
There was a problem hiding this comment.
We seem to only check top-level keys then. Is that the expected behavior?
There was a problem hiding this comment.
Yes, top-level only is intentional. Nested sub-key removal (e.g. dropping a field inside custom_response while keeping the object) isn't handled — whether that's a gap depends on whether the API replaces nested objects or deep-merges them, which I haven't confirmed. I'll add a probe test against custom_response to settle it, and recurse the diff if needed.
There was a problem hiding this comment.
Worth grounding first: source and destination updates both use PUT /sources/{id} and PUT /destinations/{id} (sdk.go:187 / sdk.go:180) — there's no PATCH on either endpoint in the spec. So "partial nested update" here means PUT-ing a config body with a nested object whose inner fields are omitted, against an endpoint that already (unusually) does top-level merge on the PUT body — the behavior this PR fixes.
Spot-checked the spec to be sure: SourceCustomResponse does declare required: [content_type, body], so the 422 there when dropping either field is consistent with the spec — that was a poor probe choice on my part, the spec already forbade the shape I was sending.
The real finding is HMAC auth: SourceConfigHTTPAuthHMAC declares no required array, yet the API still rejects with config.auth.header_key is required when header_key is dropped. So the OpenAPI spec under-declares required fields on HMAC (and probably the other HTTP auth variants, though I didn't probe those) — worth flagging with the API team separately.
In the two nested objects probed (custom_response and HMAC auth), the API rejects any PUT body where a nested object is missing inner fields — so we never actually observe replace-vs-deep-merge, because the API short-circuits before merge semantics matter. That's a pattern across what I tested, not a proof across the whole schema, but it's enough to say the top-level-only diff in toUpdatePayload can't be reached with valid input that would clear a sub-key incorrectly. Leaving the recursion out on that basis.
Added TestAccSourceResource_NestedConfigObjectsValidatedWhole (ac81ff8) to lock the HMAC case in: it applies a full HMAC auth block, then attempts to update with header_key removed and expects a 422. If the API ever loosens that validation and step 2 starts succeeding, the test fails and that's the signal to revisit whether the diff needs to recurse into nested objects.
There was a problem hiding this comment.
Followup: checked the API source to settle the replace-vs-deep-merge question instead of leaving it inferred from the 422s.
In server/controllers/Source.ts:58-87, the merge is purely top-level on config. For each legacy field (allowed_http_methods, custom_response), the controller does: if the key is present in the request, take the new value as-is; if omitted, preserve the existing value; otherwise null. auth follows the same pattern — the entire new auth object replaces the prior verification.configs with no merging.
So nested objects are replaced wholesale, not deep-merged. The 422s we observed earlier were schema validation (IntegrationService rejecting HMAC configs missing header_key; Joi rejecting custom_response missing body) — both run before the merge logic and aren't related to merge semantics.
Net: the top-level-only diff in toUpdatePayload is correct by construction, not just empirically defensible. If a nested object somewhere has truly optional inner fields, a user dropping one would just work — the new (smaller) object lands as-is and the inner key is gone. No recursion needed in the provider.
| } | ||
|
|
||
| func (m *destinationResourceModel) Update(ctx context.Context, client *sdkclient.Client) diag.Diagnostics { | ||
| func (m *destinationResourceModel) Update(ctx context.Context, client *sdkclient.Client, priorConfig string) diag.Diagnostics { |
There was a problem hiding this comment.
I think we can use a better data structure for priorConfig here instead of string. Why not destinationResourceModel for example?
There was a problem hiding this comment.
Done in 5726e6c — Update/toUpdatePayload now take the typed prior model instead of a config string.
Addresses review feedback: Update and toUpdatePayload now take the prior *destinationResourceModel / *sourceResourceModel rather than a raw config string, so the diff logic pulls .Config itself and has the full prior model available. Nil-safe for the (unreachable in Update) empty case. https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Addresses review feedback: the test fixture loader now takes *testing.T, calls t.Helper(), and reports failures via t.Fatalf so the failing fixture is reported with clean test context instead of a panic. https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Adds TestAccSourceResource_RemoveNestedKey: sets custom_response with content_type + body, then removes only body while keeping the object. The provider's diff is top-level only, so it sends the new custom_response as-is. The result reveals how the API treats nested objects on update: - body absent -> API replaces nested objects wholesale; top-level diff is sufficient and this stands as regression coverage. - body present -> API deep-merges; toUpdatePayload would need to recurse to clear nested sub-keys. Pushing to let CI run it against the API and settle the question raised in review about top-level-only diffing. https://claude.ai/code/session_0157WHRYs4bD92XG8vmmh1vz
Replaces the inconclusive RemoveNestedKey probe (which 422'd before reaching the merge logic because custom_response requires both inner fields) with an invariant test that locks in why toUpdatePayload's top-level-only diff is correct: the Hookdeck API rejects any nested config object missing a required inner field, so no user-input shape would require recursing into inner keys to clear them. If the API ever loosens validation, this test fails and forces a revisit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.agents/, .claude/, and skills-lock.json are created by Claude Code sessions and the hookdeck/agent-skills installer and should not be checked in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed against the API source (server/controllers/Source.ts:58-87) that the config merge is top-level only and nested objects are replaced wholesale, so the top-level-only diff in toUpdatePayload is correct by construction — no need for a test to lock in the assumption. The probe was a detour to settle that question empirically; with the source-of-truth answer in hand, the test is now just asserting an API-side validation rule (HMAC requires header_key) that isn't this provider's concern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
Updatemethods in bothdestination/sdk.goandsource/sdk.goto accept the prior configuration state as a parametertoUpdatePayloadmethods to both destination and source models that:toPayload()methodnullin the update payload to ensure they are deleted on the API sidedestination/resource.goandsource/resource.goto:Update()Update()methodImplementation 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
nullin 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