Closes the last follow-up from the Caddy-token audit. external-dns was the only Cloudflare consumer with no rotation automation; now that it holds a dedicated single-zone token, it can roll in place like the cert-manager one.
Already tested end-to-end and enabled (suspend: false), same as #310.
What it does
PUT /user/tokens/{id}/value — keeps scope and policy, changes the value. Then patches OpenBao apps/external-dns.
Unlike the cert-manager rotator, this one needs Reloader. cert-manager re-reads its Secret on each DNS-01 challenge; external-dns reads CF_API_TOKEN as a startup env var, so without a restart it keeps using a value that is already invalid. This job exercises the annotation added in #311 — and it works:
12:30:02 reloader: Changes detected in 'cloudflare-api-token' ...; updated 'external-dns' of type 'Deployment'
t+10s Reloader restarted it automatically: external-dns-6c8d4654f9-lkjjx
No manual restart — which is the whole point, since the cutover in #311 did need one.
Token isolation
Reuses the existing cloudflare bootstrap so the admin-token exists in exactly one place and is rolled once, but pins a separate id so the two roll independently:
Field
Token
Rolled by
token-id
Kubernetes Cert-Manager
rotator-cloudflare
external-dns-token-id
Kubernetes External-DNS
this job
Verified by hash before/after: external-dns's token changed, cert-manager's did not, and all 27 Certificates stayed True.
Worth being explicit about the limit of the isolation: because the bootstrap is shared, this policy can read the same admin-token as rotator-cloudflare, so both rotators are equally privileged toward Cloudflare. The isolation that still holds is on the target paths — this policy can patch only apps/external-dns, never infrastructure/cloudflare. That tradeoff is documented in the policy file; the alternative was a second copy of the admin token to keep in sync.
Test evidence
job completed in 5s; OpenBao patched
ESO synced; Reloader restarted the Deployment unprompted in 10s
external-dns resumed: All records are already up to date, 0 error lines
stored value verifies as active id=93affa8e and carries no trailing newline — the failure mode that crash-looped external-dns during the #311 cutover (invalid header field value for Authorization). This job passes the API value through verbatim rather than via a file, so that class of bug cannot recur here.
refreshInterval 1h → 10m, matching the rest of the rotation fleet
Bootstrap applied
bao policy write rotator-external-dns .../policies/rotator-external-dns.hcl
bao write auth/kubernetes/role/rotator-external-dns \
bound_service_account_names=external-dns-secret-rotator \
bound_service_account_namespaces=openbao policies=rotator-external-dns ttl=15m
bao kv patch .../secret-rotators-bootstrap/cloudflare external-dns-token-id=<id>
The SA and CronJob were applied by hand for the test and will be adopted by Flux on merge (same names).
Closes the last follow-up from the Caddy-token audit. external-dns was the only Cloudflare consumer with **no** rotation automation; now that it holds a dedicated single-zone token, it can roll in place like the cert-manager one.
Already tested end-to-end and enabled (`suspend: false`), same as #310.
## What it does
`PUT /user/tokens/{id}/value` — keeps scope and policy, changes the value. Then patches OpenBao `apps/external-dns`.
**Unlike the cert-manager rotator, this one needs Reloader.** cert-manager re-reads its Secret on each DNS-01 challenge; external-dns reads `CF_API_TOKEN` as a startup env var, so without a restart it keeps using a value that is already invalid. This job exercises the annotation added in #311 — and it works:
```
12:30:02 reloader: Changes detected in 'cloudflare-api-token' ...; updated 'external-dns' of type 'Deployment'
t+10s Reloader restarted it automatically: external-dns-6c8d4654f9-lkjjx
```
No manual restart — which is the whole point, since the cutover in #311 did need one.
## Token isolation
Reuses the existing cloudflare bootstrap so the admin-token exists in exactly one place and is rolled once, but pins a **separate** id so the two roll independently:
| Field | Token | Rolled by |
|---|---|---|
| `token-id` | `Kubernetes Cert-Manager` | `rotator-cloudflare` |
| `external-dns-token-id` | `Kubernetes External-DNS` | this job |
Verified by hash before/after: external-dns's token changed, **cert-manager's did not**, and all 27 Certificates stayed `True`.
Worth being explicit about the limit of the isolation: because the bootstrap is shared, this policy can read the same admin-token as `rotator-cloudflare`, so both rotators are equally privileged toward Cloudflare. The isolation that still holds is on the **target** paths — this policy can patch only `apps/external-dns`, never `infrastructure/cloudflare`. That tradeoff is documented in the policy file; the alternative was a second copy of the admin token to keep in sync.
## Test evidence
- job completed in 5s; OpenBao patched
- ESO synced; Reloader restarted the Deployment unprompted in 10s
- external-dns resumed: `All records are already up to date`, **0** error lines
- stored value verifies as `active id=93affa8e` and carries **no trailing newline** — the failure mode that crash-looped external-dns during the #311 cutover (`invalid header field value for Authorization`). This job passes the API value through verbatim rather than via a file, so that class of bug cannot recur here.
- `refreshInterval` 1h → 10m, matching the rest of the rotation fleet
## Bootstrap applied
```
bao policy write rotator-external-dns .../policies/rotator-external-dns.hcl
bao write auth/kubernetes/role/rotator-external-dns \
bound_service_account_names=external-dns-secret-rotator \
bound_service_account_namespaces=openbao policies=rotator-external-dns ttl=15m
bao kv patch .../secret-rotators-bootstrap/cloudflare external-dns-token-id=<id>
```
The SA and CronJob were applied by hand for the test and will be adopted by Flux on merge (same names).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
external-dns was the last Cloudflare consumer without rotation automation. It
now holds a dedicated single-zone token, so it can roll like the cert-manager
one: PUT /user/tokens/{id}/value keeps scope and policy, changes the value.
Unlike the cert-manager rotator, this needs Reloader — external-dns reads
CF_API_TOKEN as a startup env var. The annotation added in #311 is exercised
by this job and confirmed working: Reloader restarted the Deployment 10s after
the Secret changed, with no manual intervention.
Reuses the existing cloudflare bootstrap so the admin-token lives in one place,
but pins a SEPARATE token-id (external-dns-token-id) so the two tokens roll
independently. Verified: rolling external-dns left the cert-manager token
untouched and all 27 Certificates True.
ExternalSecret refresh 1h -> 10m to match the rest of the rotation fleet.
Tested end-to-end: job completed in 5s, OpenBao patched, ESO synced, Reloader
restarted the pod automatically, external-dns resumed with 0 errors, and the
stored value verifies as the correct active token with no trailing newline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes the last follow-up from the Caddy-token audit. external-dns was the only Cloudflare consumer with no rotation automation; now that it holds a dedicated single-zone token, it can roll in place like the cert-manager one.
Already tested end-to-end and enabled (
suspend: false), same as #310.What it does
PUT /user/tokens/{id}/value— keeps scope and policy, changes the value. Then patches OpenBaoapps/external-dns.Unlike the cert-manager rotator, this one needs Reloader. cert-manager re-reads its Secret on each DNS-01 challenge; external-dns reads
CF_API_TOKENas a startup env var, so without a restart it keeps using a value that is already invalid. This job exercises the annotation added in #311 — and it works:No manual restart — which is the whole point, since the cutover in #311 did need one.
Token isolation
Reuses the existing cloudflare bootstrap so the admin-token exists in exactly one place and is rolled once, but pins a separate id so the two roll independently:
token-idKubernetes Cert-Managerrotator-cloudflareexternal-dns-token-idKubernetes External-DNSVerified by hash before/after: external-dns's token changed, cert-manager's did not, and all 27 Certificates stayed
True.Worth being explicit about the limit of the isolation: because the bootstrap is shared, this policy can read the same admin-token as
rotator-cloudflare, so both rotators are equally privileged toward Cloudflare. The isolation that still holds is on the target paths — this policy can patch onlyapps/external-dns, neverinfrastructure/cloudflare. That tradeoff is documented in the policy file; the alternative was a second copy of the admin token to keep in sync.Test evidence
All records are already up to date, 0 error linesactive id=93affa8eand carries no trailing newline — the failure mode that crash-looped external-dns during the #311 cutover (invalid header field value for Authorization). This job passes the API value through verbatim rather than via a file, so that class of bug cannot recur here.refreshInterval1h → 10m, matching the rest of the rotation fleetBootstrap applied
The SA and CronJob were applied by hand for the test and will be adopted by Flux on merge (same names).
🤖 Generated with Claude Code
external-dns was the last Cloudflare consumer without rotation automation. It now holds a dedicated single-zone token, so it can roll like the cert-manager one: PUT /user/tokens/{id}/value keeps scope and policy, changes the value. Unlike the cert-manager rotator, this needs Reloader — external-dns reads CF_API_TOKEN as a startup env var. The annotation added in #311 is exercised by this job and confirmed working: Reloader restarted the Deployment 10s after the Secret changed, with no manual intervention. Reuses the existing cloudflare bootstrap so the admin-token lives in one place, but pins a SEPARATE token-id (external-dns-token-id) so the two tokens roll independently. Verified: rolling external-dns left the cert-manager token untouched and all 27 Certificates True. ExternalSecret refresh 1h -> 10m to match the rest of the rotation fleet. Tested end-to-end: job completed in 5s, OpenBao patched, ESO synced, Reloader restarted the pod automatically, external-dns resumed with 0 errors, and the stored value verifies as the correct active token with no trailing newline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>