Skip to content

fix: config merging in source/destination updates#211

Merged
leggetter merged 17 commits into
mainfrom
claude/fix-terraform-rate-limits-aXPfL
May 28, 2026
Merged

fix: config merging in source/destination updates#211
leggetter merged 17 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 thread internal/provider/source/resource.go Outdated
Comment thread internal/provider/destination/resource.go Outdated
Comment thread go.mod
Comment thread internal/provider/source/sdk.go
Comment thread internal/provider/destination/sdk.go
Comment thread internal/provider/source/sdk.go Outdated
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
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Comment thread internal/provider/destination/resource_test.go Outdated
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>
Copy link
Copy Markdown
Collaborator

@alexluong alexluong left a comment

Choose a reason for hiding this comment

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

LGTM, left a few comments! Appreciate the testing setup as well

Comment on lines +280 to +284
var nonNullableConfigDefaults = map[string]map[string]interface{}{
"HTTP": {
"allowed_http_methods": []interface{}{"PUT", "POST", "PATCH", "DELETE"},
},
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

@leggetter leggetter May 27, 2026

Choose a reason for hiding this comment

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

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.

Comment on lines +313 to +316
for key, priorValue := range priorPayloadConfig {
if _, exists := newPayloadConfig[key]; exists {
continue
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We seem to only check top-level keys then. Is that the expected behavior?

Copy link
Copy Markdown
Collaborator Author

@leggetter leggetter May 27, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/provider/destination/sdk.go Outdated
}

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we can use a better data structure for priorConfig here instead of string. Why not destinationResourceModel for example?

Copy link
Copy Markdown
Collaborator Author

@leggetter leggetter May 27, 2026

Choose a reason for hiding this comment

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

Done in 5726e6cUpdate/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
claude and others added 5 commits May 27, 2026 15:41
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>
@leggetter leggetter merged commit 86fd198 into main May 28, 2026
4 checks passed
@leggetter leggetter deleted the claude/fix-terraform-rate-limits-aXPfL branch May 28, 2026 09:47
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.

4 participants