Skip to content

fix: atomic bulk apply - #24

Open
sshine wants to merge 9 commits into
michelangelomo:mainfrom
sshine:fix/atomic-bulk-apply
Open

fix: atomic bulk apply#24
sshine wants to merge 9 commits into
michelangelomo:mainfrom
sshine:fix/atomic-bulk-apply

Conversation

@sshine

@sshine sshine commented Jun 29, 2026

Copy link
Copy Markdown

fix(provider): apply DNS changes as one atomic bulk request per domain

ApplyChanges:

  • Merge Create + UpdateNew + Delete for each domain into a single
    BulkUpdate(FullResource) PUT instead of three ordered, non-atomic calls.
  • Encode deletions as records:[] so deSEC validates the resulting zone state.

Why:

  • A record-type change (A->CNAME at one subname) reaches external-dns as
    Delete(old A) + Create(new CNAME). The old code POSTed the CNAME first,
    while the A still existed; deSEC rejects a CNAME coexisting with another
    type, the POST 400s, and the Delete never runs -- a permanent wedge under
    policy=sync. Sending both in one atomic bulk request lets deSEC validate
    the final state (A gone, CNAME present) and accept it.
  • BulkUpdate(FullResource) only touches the rrsets in the request (BulkDelete
    already relies on this), so unrelated records are left intact.

tests:

  • desecMock: in-memory zone enforcing deSEC's create-only POST and
    CNAME-coexistence rules, validating resulting state atomically.
  • Pin the CNAME-over-A rejection; repro the A->CNAME retype (red before fix);
    assert update+create collapse to a single bulk request; dry-run hits no API.

sshine added 9 commits May 20, 2026 12:34
When deSEC returns 429 Too Many Requests, the response carries a Retry-After header indicating how long the throttle
window will remain active. Until now the webhook ignored that header at the client level: on the next reconcile (1m)
external-dns would POST /records again and we'd immediately hit deSEC, extending the throttle window.

This change adds:

- rateLimitTransport: an http.RoundTripper that wraps the http.Client passed to
  nrdcg/desec. On any 429 response it parses Retry-After (delta-seconds or
  HTTP-date per RFC 7231) and records the deadline.
- rateLimitTracker: a goroutine-safe holder for the next-allowed-at timestamp.
  The window only ever extends, never shrinks, so a later 429 with a smaller
  Retry-After can't accidentally release us early.
- ApplyChanges short-circuit: at the top of ApplyChanges, if the tracker reports
  we're still inside the window, return a typed RateLimitError without hitting
  the API. external-dns still gets a 500 and will keep retrying, but our daily
  quota is preserved until the window legitimately expires.

The error type is modeled after the one proposed in the upstream draft PR michelangelomo#17 so
the API stays consistent if that work eventually replaces nrdcg/desec.
Adds a single debug-gated body dump at the top of both /records and /adjustendpoints handlers.

The dump fires only when WEBHOOK_LOGLEVEL=debug is set; at info level (the default) the helper
short-circuits before reading the body, so production deployments pay no cost.

Motivation:

The existing summary log line "applying changes: N creates, M updates, K deletes" and the per-domain
"updating ... records: <payload>" line both show only the UpdateNew side of the plan.

Once a diff loop appears with N creates = K deletes = 0 and M updates > 0, the visible payload does
not reveal what external-dns thought was different: UpdateOld, Labels, ProviderSpecific, and ownerID
are nowhere in the current logs.
external-dns sources emit Endpoint.DNSName without a trailing dot.

convertRRSetToEndpoint was appending one unconditionally, so /records returned the dotted form.

The external-dns plan calculator compares dotted current state against dotless desired state, and
the TXT registry treats that string mismatch as a stale companion record. It then set providerSpecific
txt/force-update=true on every UpdateOld entry, which plan.providerSpecificChanged() promoted into
an Update.

Tests:
- existing convertRRSetToEndpoint cases updated to assert dotless DNSName (including the "Apex with
  dotted domain" case that proves inbound domain strings with a trailing dot still get normalized)
- new TestConvertRRSetToEndpoint_DNSNameHasNoTrailingDot pins the invariant across subdomain, apex,
  deep subdomain, and dotted-domain input so a future refactor cannot silently reintroduce the loop
v0.11.1 tags `RRSet.SubName` with `json:"subname,omitempty"`.

This drops the field for apex RRsets (where the value must be `""`).

The deSEC API requires the field be present even at the apex.

So apex record creates land with 400 {"subname":["This field is required."]}.

Upstream commit nrdcg/desec@fd7f5e0 ("fix: subname as empty string") removed the omitempty modifier, shipped in v0.11.2.

Bumping picks up the fix. TestApexRRSetJSONIncludesEmptySubname confirms that this works and guards against regressions.
ApplyChanges:
- Merge Create + UpdateNew + Delete for each domain into a single
  BulkUpdate(FullResource) PUT instead of three ordered, non-atomic calls.
- Encode deletions as records:[] so deSEC validates the resulting zone state.

Why:
- A record-type change (A->CNAME at one subname) reaches external-dns as
  Delete(old A) + Create(new CNAME). The old code POSTed the CNAME first,
  while the A still existed; deSEC rejects a CNAME coexisting with another
  type, the POST 400s, and the Delete never runs -- a permanent wedge under
  policy=sync. Sending both in one atomic bulk request lets deSEC validate
  the final state (A gone, CNAME present) and accept it.
- BulkUpdate(FullResource) only touches the rrsets in the request (BulkDelete
  already relies on this), so unrelated records are left intact.

tests:
- desecMock: in-memory zone enforcing deSEC's create-only POST and
  CNAME-coexistence rules, validating resulting state atomically.
- Pin the CNAME-over-A rejection; repro the A->CNAME retype (red before fix);
  assert update+create collapse to a single bulk request; dry-run hits no API.
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds client-side HTTP rate limiting to the deSEC provider via a new rateLimitTransport/rateLimitTracker that intercepts 429 responses; rewrites ApplyChanges to merge creates, updates, and deletes into a single bulk PUT per domain; fixes trailing-dot DNSName formatting; adds debug request-body logging to webhook handlers; and upgrades CI/release action versions.

Changes

deSEC Rate Limiting Infrastructure

Layer / File(s) Summary
RateLimitError, tracker, and transport
internal/provider/ratelimit.go, internal/provider/ratelimit_test.go
RateLimitError (with RetryAfter), rateLimitTracker (record/wait), rateLimitTransport (429-intercepting RoundTripper), and parseRetryAfter are introduced and fully unit-tested.
Rate limiter wired into DesecClient and ApplyChanges
go.mod, internal/provider/desec.go, internal/provider/ratelimit_test.go
DesecClient gains a rateLimit field; CreateDesecClient builds a custom http.Client with rateLimitTransport; ApplyChanges short-circuits with RateLimitError when throttled and otherwise merges all create/update/delete operations into a single BulkUpdate per domain.

Bulk Apply Rewrite and DNSName Fix

Layer / File(s) Summary
DNSName trailing dot fix and tests
internal/provider/desec.go, internal/provider/desec_test.go
convertRRSetToEndpoint trims trailing dots from composed DNSName; existing test expectations updated; new tests assert dotless output and that apex RRSet JSON includes "subname":"".
ApplyChanges mock server integration tests
internal/provider/desec_apply_test.go
In-memory desecMock enforces deSEC constraints (CNAME coexistence, create-only POST); tests cover CNAME-over-A rejection, A-to-CNAME retype, combined bulk request for same domain, and dry-run no-op.

Server Debug Request Body Logging

Layer / File(s) Summary
dumpRequestBodyAtDebug helper and handler wiring
internal/server/server.go, internal/server/server_test.go
New dumpRequestBodyAtDebug helper reads, logs, and rewinds r.Body; called at the start of applyChangesHandler and adjustEndpointsHandler; tests verify the dump appears at debug level and is absent at info level.

CI and Release Action Version Upgrades

Layer / File(s) Summary
Action version bumps
.github/workflows/ci.yml, .github/workflows/release.yml
workflow_dispatch trigger added; actions/checkout, actions/setup-go, Docker build/push/login/buildx, artifact upload/download, cosign, metadata, and softprops/action-gh-release all bumped to newer major versions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the atomic bulk apply change.
Description check ✅ Passed The description clearly matches the bulk-apply atomicity fix and the added tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sshine

sshine commented Jun 29, 2026

Copy link
Copy Markdown
Author

Note: The change lives in 4128852, but is obscured slightly by main branch being so much ahead. There are some fixes to other things that I didn't publish PRs on yet. This PR mostly exists as a reminder to eventually merge.

The updated package (including other fixes) can be fetched from here:

docker pull ghcr.io/sshine/external-dns-desec-provider:0.3.4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/provider/desec_apply_test.go (1)

249-260: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the retype is applied as one bulk request.

This test only checks the final zone state. A future delete-then-create implementation would still pass here while reintroducing the non-atomic behavior this PR is meant to prevent. Please also assert m.requestCount == 1 so the regression guard pins the atomic contract.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 303db28f-1938-446f-b5e9-d83c2d6adc92

📥 Commits

Reviewing files that changed from the base of the PR and between c1777f5 and 4128852.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (10)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • go.mod
  • internal/provider/desec.go
  • internal/provider/desec_apply_test.go
  • internal/provider/desec_test.go
  • internal/provider/ratelimit.go
  • internal/provider/ratelimit_test.go
  • internal/server/server.go
  • internal/server/server_test.go

Comment on lines 87 to +91
func (d *DesecClient) ApplyChanges(changes plan.Changes) error {
if remaining := d.rateLimit.wait(); remaining > 0 {
log.Warnf("deSEC rate limit active; skipping ApplyChanges for %s to preserve daily quota", remaining)
return &RateLimitError{RetryAfter: remaining}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don't rate-limit the dry-run path.

Lines 88-90 run before the dry-run branch, so a prior 429 makes ApplyChanges return RateLimitError even when d.dryRun is true and no API call would be made. That turns --dry-run into a false failure during a throttle window.

Suggested fix
 func (d *DesecClient) ApplyChanges(changes plan.Changes) error {
-	if remaining := d.rateLimit.wait(); remaining > 0 {
-		log.Warnf("deSEC rate limit active; skipping ApplyChanges for %s to preserve daily quota", remaining)
-		return &RateLimitError{RetryAfter: remaining}
+	if !d.dryRun {
+		if remaining := d.rateLimit.wait(); remaining > 0 {
+			log.Warnf("deSEC rate limit active; skipping ApplyChanges for %s to preserve daily quota", remaining)
+			return &RateLimitError{RetryAfter: remaining}
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (d *DesecClient) ApplyChanges(changes plan.Changes) error {
if remaining := d.rateLimit.wait(); remaining > 0 {
log.Warnf("deSEC rate limit active; skipping ApplyChanges for %s to preserve daily quota", remaining)
return &RateLimitError{RetryAfter: remaining}
}
func (d *DesecClient) ApplyChanges(changes plan.Changes) error {
if !d.dryRun {
if remaining := d.rateLimit.wait(); remaining > 0 {
log.Warnf("deSEC rate limit active; skipping ApplyChanges for %s to preserve daily quota", remaining)
return &RateLimitError{RetryAfter: remaining}
}
}

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.

1 participant