From 86ded313a97d08829954eeacd62fe2501cd9bcc4 Mon Sep 17 00:00:00 2001 From: Erick Bourgeois Date: Tue, 7 Jul 2026 22:28:09 -0400 Subject: [PATCH] Multiple bug fixes including the following 1. DNS record ops (RRset replace, delete error handling, TTL, IPv6) 2. Records reconciler (orphan cleanup + publishedName, records patch key, failure-path status) 3. bind9 HTTP/auth (token rotation, zone_exists 404, timeouts, rndc parser) - Data-plane safety: MX/TXT/NS/SRV/CAA now replace RRsets instead of appending duplicates; deletion failures (NOTAUTH/REFUSED) surface as errors; unselected/renamed records are cleaned out of BIND9 (new publishedName status field); transient API errors can no longer trigger deletion of live DNS records. - Availability: the bindcar auth token is re-read on rotation (no more 401s after 1h); HTTP timeouts let retry/backoff actually work; zone Ready is now honest (instance-based counting, Ready=False on failures). - Convergence: the observedGeneration family is fixed at all three sites plus the new observedParentGeneration field, ending several permanent reconcile loops; drift detection checks the right namespaces; finalizers use guarded JSON patches and no longer deadlock deletion when pods are down. - Deployability: custom configMapRefs and rndcKey.secretRef actually work now; DNSSEC signing no longer crashloops named; forwarders/listenOn are rendered; bindy bootstrap operator produces a 0.7-authenticating operator. Signed-off-by: Erick Bourgeois --- .claude/CHANGELOG.md | 145 +++ .claude/skills/verify-crd-sync/SKILL.md | 85 ++ Cargo.lock | 1 + Cargo.toml | 5 +- deploy/operator/crds/aaaarecords.crd.yaml | 11 + deploy/operator/crds/arecords.crd.yaml | 11 + deploy/operator/crds/bind9instances.crd.yaml | 16 + deploy/operator/crds/caarecords.crd.yaml | 11 + deploy/operator/crds/cnamerecords.crd.yaml | 11 + deploy/operator/crds/mxrecords.crd.yaml | 11 + deploy/operator/crds/nsrecords.crd.yaml | 11 + deploy/operator/crds/srvrecords.crd.yaml | 11 + deploy/operator/crds/txtrecords.crd.yaml | 11 + docs/src/reference/api.md | 11 +- src/bind9/mod.rs | 166 ++- src/bind9/mod_tests.rs | 115 ++ src/bind9/records/a.rs | 78 +- src/bind9/records/a_tests.rs | 105 ++ src/bind9/records/caa.rs | 95 +- src/bind9/records/caa_tests.rs | 68 + src/bind9/records/cname.rs | 44 +- src/bind9/records/cname_tests.rs | 43 + src/bind9/records/mod.rs | 96 +- src/bind9/records/mod_tests.rs | 95 ++ src/bind9/records/mx.rs | 68 +- src/bind9/records/mx_tests.rs | 63 + src/bind9/records/ns.rs | 56 +- src/bind9/records/ns_tests.rs | 31 + src/bind9/records/srv.rs | 81 +- src/bind9/records/srv_tests.rs | 96 ++ src/bind9/records/txt.rs | 65 +- src/bind9/records/txt_tests.rs | 33 + src/bind9/rndc.rs | 69 +- src/bind9/rndc_tests.rs | 103 ++ src/bind9/zone_ops.rs | 255 ++-- src/bind9/zone_ops_tests.rs | 250 +++- src/bind9_resources.rs | 260 ++-- src/bind9_resources_tests.rs | 660 ++++++++-- src/bootstrap.rs | 168 ++- src/bootstrap_tests.rs | 183 ++- src/crd.rs | 22 + src/crd_tests.rs | 8 + src/main.rs | 14 +- .../bind9cluster/status_helpers.rs | 107 +- .../bind9cluster/status_helpers_tests.rs | 86 ++ src/reconcilers/bind9instance/mod.rs | 143 ++- src/reconcilers/bind9instance/mod_tests.rs | 42 + src/reconcilers/bind9instance/resources.rs | 233 ++-- .../bind9instance/resources_tests.rs | 135 ++ .../bind9instance/status_helpers.rs | 130 +- .../bind9instance/status_helpers_tests.rs | 94 ++ src/reconcilers/clusterbind9provider.rs | 286 +++-- src/reconcilers/clusterbind9provider_tests.rs | 94 ++ src/reconcilers/dnszone.rs | 153 ++- src/reconcilers/dnszone/bind9_config.rs | 88 +- src/reconcilers/dnszone/cleanup.rs | 75 +- src/reconcilers/dnszone/cleanup_tests.rs | 56 + src/reconcilers/dnszone/discovery.rs | 399 ++++-- src/reconcilers/dnszone/discovery_tests.rs | 82 ++ src/reconcilers/dnszone/helpers.rs | 166 ++- src/reconcilers/dnszone/helpers_tests.rs | 121 ++ src/reconcilers/dnszone/primary.rs | 56 +- src/reconcilers/dnszone/secondary.rs | 58 +- src/reconcilers/dnszone/status_helpers.rs | 148 ++- .../dnszone/status_helpers_tests.rs | 179 +++ src/reconcilers/dnszone/types.rs | 15 + src/reconcilers/finalizers.rs | 381 ++++-- src/reconcilers/finalizers_tests.rs | 83 ++ src/reconcilers/records/mod.rs | 1098 ++++++----------- src/reconcilers/records/status_helpers.rs | 14 + src/reconcilers/records_tests.rs | 134 ++ src/record_wrappers_tests.rs | 1 + templates/named.conf.options.tmpl | 6 +- 73 files changed, 6380 insertions(+), 2024 deletions(-) create mode 100644 .claude/skills/verify-crd-sync/SKILL.md diff --git a/.claude/CHANGELOG.md b/.claude/CHANGELOG.md index d3dfcd05..c6947efd 100644 --- a/.claude/CHANGELOG.md +++ b/.claude/CHANGELOG.md @@ -1,3 +1,148 @@ +## [2026-07-08] - Regression run + verify-crd-sync skill + +**Author:** Erick Bourgeois + +### Changed +- `deploy/operator/crds/bind9instances.crd.yaml`: regenerated — the merge of the + bug-fix batches left this CRD stale (missing `status.observedParentGeneration`); + a fresh `cargo run --bin crdgen` corrected it. All other CRDs were in sync. +- `.claude/skills/verify-crd-sync/SKILL.md`: new invocable skill (the reference in + `.claude/SKILL.md` was prose-only, so the Skill tool could not run it). Folds in + the offline drift check (`crdgen` + `git diff`) that caught the stale CRD above, + and corrects the CRD path (`deploy/operator/crds/`, not `deploy/crds/`). + +### Regression results (compiled suite, `cargo test --all`) +- 1253 passed, 0 failed, 89 ignored (the 89 are cluster-required integration tests). +- `cargo fmt` clean, `cargo clippy --all-targets -D warnings` clean. +- CRD sync verified (see above). All 19 `examples/*.yaml` are well-formed. +- NOT run (require a rebuilt operator image + deploy, which is the user's step): + `make test-integration`, `test-integ-multi-tenancy`, `test-integ-cluster-provider`, + and the kind admission-policy phases. + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [ ] Config change only +- [x] Tooling / CRD regeneration only + +--- + +## [2026-07-07] - Bug-fix sweep: 24 bugs from codebase-wide audit (branch major-bug-fixes) + +**Author:** Erick Bourgeois + +Codebase-wide correctness audit (4 parallel reviewers + hand verification) +followed by six fix batches. All fixes TDD-first with ~130 new unit tests; +`cargo fmt`/`clippy -D warnings`/`cargo test` green throughout. + +### Changed — DNS record operations (`src/bind9/records/`) +- TXT/MX/NS/SRV/CAA add paths now **replace** the RRset (delete-then-append via + shared `build_delete_rrset_record`), matching A/AAAA. Previously they only + appended, so spec changes served old + new rdata forever (broken SPF, stale MX). +- `delete_dns_record` now fails on REFUSED/NOTAUTH/NOTZONE/SERVFAIL; only + NoError/NXDomain/NXRRSet are idempotent success (`is_idempotent_delete_response_code`). + Previously TSIG/ACL failures returned Ok and orphaned records past finalizers. +- TTL-only spec changes now trigger updates (all 8 types compare TTL via + `effective_record_ttl`/`rrset_ttl_matches`). +- IPv6 RRset comparison parses both sides to `Ipv6Addr` (non-canonical spellings + like `2001:DB8::1` no longer cause endless delete/recreate churn). + +### Changed — records reconciler (`src/reconcilers/records/`, `dnszone/discovery.rs`) +- `update_record_reconciled_timestamp` patched nonexistent `status.selectedRecords` + (schema-pruned no-op); now patches `records`. `lastReconciledAt` persists for + the first time. +- Records unselected from a zone are now deleted from BIND9 before untagging + (`cleanup_unselected_record_dns`; untag deferred on DNS failure for retry). +- **New CRD status field `publishedName`** on all 8 record types: tracks the + published FQDN so renames delete the old name; finalizer prefers it over spec.name. +- 7 duplicated per-type reconcilers collapsed into generic `reconcile_record` + (net −970 lines). +- `status.addresses` only published on successful, selected reconciles. +- Transient record-list failures no longer wipe `DNSZone.status.records`. + +### Changed — bindcar HTTP/auth layer (`src/bind9/`) +- SA token no longer cached for process lifetime: `RwLock`-guarded cache with + freshness TTL re-reads the kubelet-rotated projected token (was: permanent 401s + ~1h after operator start under bindcar 0.7 TokenReview). +- HTTP client now has connect (5s) / request (30s) timeouts; retry/backoff can + actually engage on hung connections. `create_zone_http` routed through the + shared retry path. +- `zone_exists` 404 detection fixed via `is_http_not_found()` downcast through + the anyhow context chain (`Ok(false)` was unreachable). +- `parse_rndc_key_file` skips comment lines (`#`, `//`, `/* */`). + +### Changed — resource builders (`src/bind9_resources.rs`, `bind9instance/resources.rs`, `templates/`) +- Custom `configMapRefs` no longer produce broken Deployments: generated + ConfigMap always created with non-overridden files (rndc.conf always), `config` + volume always present, custom refs override per-file via subPath. Previously: + both refs → 422; one ref → FailedMount forever. +- `rndcKey.secretRef`/inline secret names now threaded into the Deployment + (env secretKeyRefs + volume). Previously hardcoded `{name}-rndc-key` (TODO). +- DNSSEC policy no longer emits invalid `nsec;` (NSEC = omitted `nsec3param` in + BIND 9.18 grammar; was CrashLoopBackOff on signing without nsec3). + `generate_dnssec_policies` fallback now matches `get_dnssec_signing_config`. +- Malformed RNDC Secrets are genuinely recreated after deletion + (`evaluate_existing_rndc_secret` pure decision fn; also removed a production unwrap). +- `Bind9Config.forwarders`/`listenOn`/`listenOnV6` are now rendered into + named.conf.options (instance + cluster paths; entries validated as IPs). + Previously silently ignored — forwarder-only setups did full recursion. + +### Changed — reconcilers (`src/reconcilers/`) +- `observedGeneration` included in status-change checks at all 3 affected sites + (ClusterBind9Provider, Bind9Cluster, Bind9Instance) — statuses no longer lag + metadata.generation, ending permanent re-reconcile loops (bug-039 family). +- **New CRD status field `Bind9InstanceStatus.observedParentGeneration`**: + parent-config change detection no longer compares the parent's generation to + the instance's own observedGeneration (unrelated counters). +- ClusterBind9Provider drift detection now checks the same namespace set that + reconciliation creates (shared `expected_cluster_namespaces()`), fixing both + perpetual false drift and never-detected real drift. +- DNSZone Ready now compares instances-configured vs instances-expected + (`ZoneConfigOutcome`); an instance counts only if ALL its ready endpoints + accepted the zone. Partial pod failures no longer masked by endpoint counts. +- Zone conditions converge on every path (`set_final_zone_conditions`): + failure sets Ready=False (was: stale Ready=True during outages); + success resolves Progressing=False. +- DNSZone cleanup only treats kube 404 as "resource deleted"; transient API + errors propagate instead of triggering the self-healing path that deleted + live DNS records. +- Deletion cleanup skips (with loud warning) instances with no ready endpoints + and missing RNDC Secrets instead of blocking finalizers forever + (`EndpointFailurePolicy::SkipUnavailable`, deletion paths only). +- Finalizer add/remove uses guarded JSON Patches (test + add/remove, mirroring + kube-runtime) instead of whole-array merge patches that clobbered concurrent + writers. New dependency: `json-patch` 4.2 (already in tree via kube-core; + no new compiled crates). +- Pending pods without IPs are skipped during pod discovery instead of aborting + the listing. + +### Changed — bootstrap (`src/bootstrap.rs`) +- `bindy bootstrap operator` can now authenticate to bindcar 0.7: projected + `audience: bindcar` token volume (3600s), `POD_NAMESPACE` downward-API env, + and TokenReview ClusterRole/Binding applied with the binding subject namespace + following `--namespace` (was hardcoded-implicitly to bindy-system). + +### Why +See `.wolf/buglog.json` bug-039..044 and the 2026-07-07 audit. Highest-impact: +records serving stale rdata indefinitely, live DNS data deleted on transient +control-plane errors, operator auth expiring after 1h, and Ready=True masking +outages. + +### Impact +- [ ] Breaking change +- [x] Requires cluster rollout (operator image + CRD update) +- [ ] Config change only +- [ ] Documentation only + +CRD changes are additive only (`publishedName`, `observedParentGeneration`); +apply CRDs with `kubectl replace --force` per the standard procedure. Behavior +changes operators should know: record deletion failures now surface as errors +(previously silent), zone Ready is stricter (partial failures now show +Ready=False), and `forwarders`/`listenOn` fields now take effect — clusters +relying on them being ignored will see config changes on next reconcile. + +--- + ## [2026-07-07] - Docs & examples: bindcar Mode B audience details **Author:** Erick Bourgeois diff --git a/.claude/skills/verify-crd-sync/SKILL.md b/.claude/skills/verify-crd-sync/SKILL.md new file mode 100644 index 00000000..65dc1e42 --- /dev/null +++ b/.claude/skills/verify-crd-sync/SKILL.md @@ -0,0 +1,85 @@ +--- +name: verify-crd-sync +description: Verify that the generated CRD YAMLs in deploy/operator/crds/ match the Rust source of truth in src/crd.rs. Use BEFORE investigating any reconciliation loop, infinite requeue, "field not appearing in kubectl output", or status patch that returns HTTP 200 but doesn't persist; and AFTER any edit to structs in src/crd.rs. Catches schema drift that causes silent field pruning. +--- + +# verify-crd-sync + +CRD YAMLs in `deploy/operator/crds/` are **auto-generated** from `src/crd.rs` by +the `crdgen` binary. When they drift, the API server prunes struct fields that +exist in Rust but not in the deployed schema: patches succeed (HTTP 200) but the +data never persists, causing silent reconciliation loops and missing `kubectl` +output. + +`src/crd.rs` is the single source of truth. Never hand-edit the YAMLs. + +## When to use + +- **After** any change to a struct in `src/crd.rs` (new field, rename, serde attr). +- **Before** investigating: reconciliation/requeue loops, a field not appearing in + `kubectl get -o yaml`, a status patch that returns 200 but doesn't stick, or any + "the controller ignores my change" report. +- As part of a regression run after merging branches that touched `src/crd.rs` + (large CRDs like `bind9instances.crd.yaml` exceed patch/merge tooling limits and + are the ones most likely to be left stale). + +## Steps + +### 1. Detect drift offline (no cluster required — preferred) + +Regenerate from Rust and diff against what's committed on disk: + +```bash +cargo run --bin crdgen +git diff --stat -- deploy/operator/crds/ +``` + +- **No files listed** → CRDs are in sync. Done. +- **Files listed** → those YAMLs were stale; `crdgen` has just corrected them. + Inspect the change to confirm it's the expected field, then keep it: + +```bash +git diff -- deploy/operator/crds/.crd.yaml +``` + +The regenerated files ARE the fix — leave them in the working tree so they get +committed alongside the `src/crd.rs` change. + +### 2. Confirm a specific field round-trips (optional, cluster required) + +If a field still misbehaves after step 1, verify the deployed cluster schema: + +```bash +# Deployed schema for the field +kubectl get crd .bindy.firestoned.io -o yaml | grep -A 20 ":" + +# Rust definition it should match +rg -A 10 "pub struct " src/crd.rs +``` + +### 3. Apply the corrected CRDs to the cluster (only when asked) + +`Bind9Instance` CRD exceeds the 256 KB annotation limit, so `kubectl apply` +fails — use `replace --force`: + +```bash +kubectl replace --force -f deploy/operator/crds/.crd.yaml +``` + +Image build/push and cluster mutations are the user's to run — surface the +command, don't execute it unprompted. + +## Verification + +- `git diff --stat -- deploy/operator/crds/` is empty immediately after + `cargo run --bin crdgen` (regenerating twice is idempotent). +- The previously-missing field appears in `kubectl get -o yaml` after the patch. +- The reconciliation loop stops (observedGeneration catches up to + metadata.generation). + +## Related + +- `regen-crds` — the generation half of this workflow (edit `src/crd.rs` → + `crdgen` → update `examples/` → `regen-api-docs` LAST). +- CRD API reference docs are regenerated separately: + `cargo run --bin crddoc > docs/src/reference/api.md`. diff --git a/Cargo.lock b/Cargo.lock index 1f298dd4..dafdea7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "futures", "hickory-net", "hickory-proto", + "json-patch", "k8s-openapi", "kube", "kube-lease-manager", diff --git a/Cargo.toml b/Cargo.toml index 7f0a21a7..b798c08c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,11 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -kube = { version = "3.1", features = ["runtime", "derive", "client"] } +kube = { version = "3.1", features = ["runtime", "derive", "client", "jsonpatch"] } kube-lease-manager = "0.11.1" +# JSON Patch construction for atomic finalizer add/remove with `test` guards +# (mirrors kube-runtime's own finalizer helper; same version kube-core uses) +json-patch = "4.2" # Only include needed tokio features for faster compilation tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "fs", "signal"] } serde = { version = "1", features = ["derive"] } diff --git a/deploy/operator/crds/aaaarecords.crd.yaml b/deploy/operator/crds/aaaarecords.crd.yaml index 42cb116d..2713c685 100644 --- a/deploy/operator/crds/aaaarecords.crd.yaml +++ b/deploy/operator/crds/aaaarecords.crd.yaml @@ -154,6 +154,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/arecords.crd.yaml b/deploy/operator/crds/arecords.crd.yaml index f7cac91b..c00ffc7b 100644 --- a/deploy/operator/crds/arecords.crd.yaml +++ b/deploy/operator/crds/arecords.crd.yaml @@ -162,6 +162,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/bind9instances.crd.yaml b/deploy/operator/crds/bind9instances.crd.yaml index dd8c1fa6..5cc41d5b 100644 --- a/deploy/operator/crds/bind9instances.crd.yaml +++ b/deploy/operator/crds/bind9instances.crd.yaml @@ -2805,6 +2805,22 @@ spec: format: int64 nullable: true type: integer + observedParentGeneration: + description: |- + Generation of the referenced parent cluster that was last reconciled. + + Records the `metadata.generation` of the parent `Bind9Cluster` or + `ClusterBind9Provider` (whichever this instance references) as observed + during the last successful reconciliation. The instance reconciler + compares the parent's current generation against this value to detect + parent configuration changes (e.g., RNDC config added at the cluster + level) that must be propagated to the instance. + + This is intentionally separate from `observed_generation`, which tracks + the instance's OWN spec generation - the two counters are unrelated. + format: int64 + nullable: true + type: integer rndcKeyRotation: description: |- RNDC key rotation status and tracking information. diff --git a/deploy/operator/crds/caarecords.crd.yaml b/deploy/operator/crds/caarecords.crd.yaml index 6cee3987..5ee240ac 100644 --- a/deploy/operator/crds/caarecords.crd.yaml +++ b/deploy/operator/crds/caarecords.crd.yaml @@ -172,6 +172,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/cnamerecords.crd.yaml b/deploy/operator/crds/cnamerecords.crd.yaml index 40e33c87..86508b95 100644 --- a/deploy/operator/crds/cnamerecords.crd.yaml +++ b/deploy/operator/crds/cnamerecords.crd.yaml @@ -153,6 +153,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/mxrecords.crd.yaml b/deploy/operator/crds/mxrecords.crd.yaml index bce04f68..086874a9 100644 --- a/deploy/operator/crds/mxrecords.crd.yaml +++ b/deploy/operator/crds/mxrecords.crd.yaml @@ -161,6 +161,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/nsrecords.crd.yaml b/deploy/operator/crds/nsrecords.crd.yaml index 8e7aed80..501277d1 100644 --- a/deploy/operator/crds/nsrecords.crd.yaml +++ b/deploy/operator/crds/nsrecords.crd.yaml @@ -147,6 +147,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/srvrecords.crd.yaml b/deploy/operator/crds/srvrecords.crd.yaml index 36c37bfe..b0bad843 100644 --- a/deploy/operator/crds/srvrecords.crd.yaml +++ b/deploy/operator/crds/srvrecords.crd.yaml @@ -180,6 +180,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/deploy/operator/crds/txtrecords.crd.yaml b/deploy/operator/crds/txtrecords.crd.yaml index cb338fa4..402992ad 100644 --- a/deploy/operator/crds/txtrecords.crd.yaml +++ b/deploy/operator/crds/txtrecords.crd.yaml @@ -148,6 +148,17 @@ spec: format: int64 nullable: true type: integer + publishedName: + description: |- + DNS record name (from `spec.name`) most recently published to BIND9. + + Set by the record reconciler after a successful dynamic DNS update. + When `spec.name` changes (a rename), the reconciler compares it against + this field, deletes the old FQDN from the zone, publishes the new name, + and then updates this field. Without it, renamed records would leave + their old FQDN orphaned in BIND9. + nullable: true + type: string recordHash: description: |- SHA-256 hash of the record's spec data. diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index d3fcde4a..a5869663 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -82,6 +82,7 @@ ARecord maps a DNS hostname to an IPv4 address. Multiple A records for the same | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -110,6 +111,7 @@ AAAARecord maps a DNS hostname to an IPv6 address. This is the IPv6 equivalent o | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -138,6 +140,7 @@ CNAMERecord creates a DNS alias from one hostname to another. A CNAME cannot coe | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -167,6 +170,7 @@ MXRecord specifies mail exchange servers for a domain. Lower priority values ind | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -195,6 +199,7 @@ NSRecord delegates a subdomain to authoritative nameservers. Used for subdomain | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -223,6 +228,7 @@ TXTRecord stores arbitrary text data in DNS. Commonly used for SPF, DKIM, DMARC | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -254,6 +260,7 @@ SRVRecord specifies the hostname and port of servers for specific services. The | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -284,6 +291,7 @@ CAARecord specifies which certificate authorities are authorized to issue certif | `conditions` | array | No | | | `lastUpdated` | string | No | Timestamp of the last successful update to BIND9. This is updated after a successful nsupdate operation. Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z"). | | `observedGeneration` | integer | No | | +| `publishedName` | string | No | DNS record name (from \`spec.name\`) most recently published to BIND9. Set by the record reconciler after a successful dynamic DNS update. When \`spec.name\` changes (a rename), the reconciler compares it against this field, deletes the old FQDN from the zone, publishes the new name, and then updates this field. Without it, renamed records would leave their old FQDN orphaned in BIND9. | | `recordHash` | string | No | SHA-256 hash of the record's spec data. Used to detect when a record's data has actually changed, avoiding unnecessary BIND9 updates and zone transfers. The hash is calculated from all fields in the record's spec that affect the DNS record data (name, addresses, TTL, etc.). | | `zone` | string | No | The FQDN of the zone that owns this record (set by \`DNSZone\` controller). When a \`DNSZone\`'s label selector matches this record, the \`DNSZone\` controller sets this field to the zone's FQDN (e.g., \`"example.com"\`). The record reconciler uses this to determine which zone to update in BIND9. If this field is empty, the record is not matched by any zone and should not be reconciled into BIND9. **DEPRECATED**: Use \`zone_ref\` instead for structured zone reference. | | `zoneRef` | object | No | Structured reference to the \`DNSZone\` that owns this record. Set by the \`DNSZone\` controller when the zone's \`recordsFrom\` selector matches this record's labels. Contains the complete Kubernetes object reference including apiVersion, kind, name, namespace, and zoneName. The record reconciler uses this to: 1. Look up the parent \`DNSZone\` resource 2. Find the zone's primary \`Bind9Instance\` servers 3. Add this record to BIND9 on primaries 4. Trigger zone transfer (retransfer) on secondaries If this field is None, the record is not selected by any zone and will not be added to BIND9. | @@ -335,7 +343,7 @@ Bind9Instance represents a BIND9 DNS server deployment in Kubernetes. Each insta | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | -| `bindcarConfig` | object | No | Bindcar RNDC API sidecar container configuration. The API container provides an **authenticated** HTTP interface for managing zones via rndc: the operator calls it with a `bindcar`-audience ServiceAccount token validated by Kubernetes TokenReview (bindcar 0.7.x "Mode B" — see the [migration guide](../operations/migration-guide.md)). Defaults to image `ghcr.io/firestoned/bindcar:v0.7.2`. **Security:** `envVars` must not override the operator-managed, security-critical variables (`BIND_TOKEN_AUDIENCES`, `BIND_ALLOWED_SERVICE_ACCOUNTS`, `BIND_API_TOKEN`, `DISABLE_AUTH`, `BIND_ALLOW_ANY_SERVICEACCOUNT`, `RNDC_*`, `KUBE_*`) — doing so can weaken authentication. If not specified, uses default configuration. | +| `bindcarConfig` | object | No | Bindcar RNDC API sidecar container configuration. The API container provides an HTTP interface for managing zones via rndc. If not specified, uses default configuration. | | `clusterRef` | string | Yes | Reference to the cluster this instance belongs to. Can reference either: - A namespace-scoped \`Bind9Cluster\` (must be in the same namespace as this instance) - A cluster-scoped \`ClusterBind9Provider\` (cluster-wide, accessible from any namespace) The cluster provides shared configuration and defines the logical grouping. The controller will automatically detect whether this references a namespace-scoped or cluster-scoped cluster resource. | | `config` | object | No | Instance-specific BIND9 configuration overrides. Overrides cluster-level configuration for this instance only. | | `configMapRefs` | object | No | \`ConfigMap\` references override. Inherits from cluster if not specified. | @@ -357,6 +365,7 @@ Bind9Instance represents a BIND9 DNS server deployment in Kubernetes. Each insta | `clusterRef` | object | No | Resolved cluster reference with full object details. This field is populated by the instance reconciler and contains the full Kubernetes object reference (kind, apiVersion, namespace, name) of the cluster this instance belongs to. This provides backward compatibility with \`spec.clusterRef\` (which is just a string name) and enables proper Kubernetes object references. For namespace-scoped \`Bind9Cluster\`, includes namespace. For cluster-scoped \`ClusterBind9Provider\`, namespace will be empty. | | `conditions` | array | No | | | `observedGeneration` | integer | No | | +| `observedParentGeneration` | integer | No | Generation of the referenced parent cluster that was last reconciled. Records the \`metadata.generation\` of the parent \`Bind9Cluster\` or \`ClusterBind9Provider\` (whichever this instance references) as observed during the last successful reconciliation. The instance reconciler compares the parent's current generation against this value to detect parent configuration changes (e.g., RNDC config added at the cluster level) that must be propagated to the instance. This is intentionally separate from \`observed_generation\`, which tracks the instance's OWN spec generation - the two counters are unrelated. | | `rndcKeyRotation` | object | No | RNDC key rotation status and tracking information. Populated when \`auto_rotate\` is enabled in the RNDC configuration. Provides visibility into key lifecycle: creation time, next rotation time, and rotation count. This field is automatically updated by the instance reconciler whenever: - A new RNDC key is generated - An RNDC key is rotated - The rotation configuration changes **Note**: Only present when using operator-managed RNDC keys. If you specify \`secret_ref\` to use an external Secret, this field will be empty. | | `serviceAddress` | string | No | IP or hostname of this instance's service | | `zones` | array | No | List of DNS zones that have selected this instance. This field is automatically populated by a status-only watcher on \`DNSZones\`. When a \`DNSZone\`'s \`status.bind9Instances\` includes this instance, the zone is added to this list. This provides a reverse lookup: instance → zones. Updated by: \`DNSZone\` status watcher (not by instance reconciler) Used for: Observability, debugging zone assignments | diff --git a/src/bind9/mod.rs b/src/bind9/mod.rs index b5e26d4e..00bb3f90 100644 --- a/src/bind9/mod.rs +++ b/src/bind9/mod.rs @@ -55,7 +55,8 @@ use bindcar::ZoneConfig; use k8s_openapi::api::apps::v1::Deployment; use reqwest::Client as HttpClient; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use tracing::{debug, warn}; /// Environment variable name that indicates bindcar authentication is enabled. @@ -69,6 +70,93 @@ const BINDCAR_AUTH_ENV_VAR: &str = "BIND_ALLOWED_SERVICE_ACCOUNTS"; /// for when inspecting the operand Deployment. const BINDCAR_CONTAINER_NAME: &str = crate::constants::CONTAINER_NAME_BINDCAR; +/// How long a `ServiceAccount` token read from disk may be served from the +/// in-memory cache before it is re-read (seconds). +/// +/// The operator's bindcar-audience token is projected with +/// `expirationSeconds: 3600` (see `deploy/operator/deployment.yaml`) and the +/// kubelet rewrites the token file at roughly 80% of that lifetime. Caching +/// the token for the process lifetime therefore presents an **expired** token +/// after ~1h, which bindcar's TokenReview rejects with a non-retryable 401. +/// Re-reading every 5 minutes keeps the presented token far fresher than the +/// rotation window; the file lives on a tmpfs projected volume, so the re-read +/// is cheap. +const TOKEN_CACHE_TTL_SECS: u64 = 300; + +/// Connect timeout for bindcar HTTP API requests. +/// +/// Without a connect timeout, a blackholed pod IP (e.g. a stale Endpoints +/// entry) hangs a reconcile task indefinitely — the retry/backoff in +/// `zone_ops::bindcar_request` never fires because attempt #1 never returns. +const BINDCAR_HTTP_CONNECT_TIMEOUT_SECS: u64 = 5; + +/// Total request timeout (connect + transfer) for bindcar HTTP API requests. +/// +/// Bounded well below `zone_ops::bindcar_request`'s 120s max retry window so +/// a slow attempt still leaves room for retries. +const BINDCAR_HTTP_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// A `ServiceAccount` token (or the absence of one) read from disk, together +/// with the time it was read. +/// +/// `token: None` means the token file could not be read (e.g. auth-disabled +/// or out-of-cluster deployments); the negative result is cached too, so a +/// missing file is not re-read on every request within the TTL. +struct CachedToken { + /// The token contents, or `None` if no token file was readable. + token: Option, + /// When the token file was last read. + read_at: Instant, +} + +// Custom Debug implementation to prevent logging the token in cleartext +impl std::fmt::Debug for CachedToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedToken") + .field("token", &self.token.as_ref().map(|_| "")) + .field("read_at", &self.read_at) + .finish() + } +} + +/// Returns `true` while a token read at `read_at` may still be served from +/// cache at `now` (i.e. it is younger than [`TOKEN_CACHE_TTL_SECS`]). +fn is_token_cache_fresh(read_at: Instant, now: Instant) -> bool { + now.saturating_duration_since(read_at) < Duration::from_secs(TOKEN_CACHE_TTL_SECS) +} + +/// Build the shared bindcar HTTP client with connect and request timeouts. +fn build_http_client() -> HttpClient { + build_http_client_with_timeouts( + Duration::from_secs(BINDCAR_HTTP_CONNECT_TIMEOUT_SECS), + Duration::from_secs(BINDCAR_HTTP_REQUEST_TIMEOUT_SECS), + ) +} + +/// Build an HTTP client with the given connect and total-request timeouts. +/// +/// Falls back to the default client (no timeouts) if the builder fails, so +/// manager construction never panics; the failure is logged. +fn build_http_client_with_timeouts( + connect_timeout: Duration, + request_timeout: Duration, +) -> HttpClient { + match HttpClient::builder() + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .build() + { + Ok(client) => client, + Err(e) => { + warn!( + error = %e, + "Failed to build HTTP client with timeouts; falling back to default client without timeouts" + ); + HttpClient::new() + } + } +} + /// Manager for BIND9 servers via HTTP API sidecar. /// /// The `Bind9Manager` provides methods for managing BIND9 servers running in Kubernetes @@ -86,8 +174,11 @@ const BINDCAR_CONTAINER_NAME: &str = crate::constants::CONTAINER_NAME_BINDCAR; pub struct Bind9Manager { /// HTTP client for API requests client: Arc, - /// `ServiceAccount` token for authentication (optional - only used if auth is enabled) - token: Arc>, + /// Cached `ServiceAccount` token for authentication (only used if auth is + /// enabled). Re-read from disk when older than [`TOKEN_CACHE_TTL_SECS`] + /// because the kubelet rotates the projected token file; caching it for + /// the process lifetime would present an expired token after ~1h. + token_cache: Arc>>, /// Deployment for the `Bind9Instance` (used to check auth status) deployment: Option>, /// Instance name (for auth checking) @@ -99,18 +190,18 @@ pub struct Bind9Manager { impl Bind9Manager { /// Create a new `Bind9Manager` without deployment information. /// - /// Reads the `ServiceAccount` token from the default location and creates - /// an HTTP client for API requests. Without deployment information, auth is - /// always assumed to be enabled (backward compatible behavior). + /// Creates an HTTP client (with connect/request timeouts) for API + /// requests. The `ServiceAccount` token is read lazily at request time and + /// cached for [`TOKEN_CACHE_TTL_SECS`] so kubelet token rotation is picked + /// up. Without deployment information, auth is always assumed to be + /// enabled (backward compatible behavior). /// /// For proper auth status detection, use `new_with_deployment()` instead. #[must_use] pub fn new() -> Self { - let token = Self::read_service_account_token().ok(); - Self { - client: Arc::new(HttpClient::new()), - token: Arc::new(token), + client: Arc::new(build_http_client()), + token_cache: Arc::new(RwLock::new(None)), deployment: None, instance_name: None, instance_namespace: None, @@ -119,10 +210,12 @@ impl Bind9Manager { /// Create a new `Bind9Manager` with deployment information for auth checking. /// - /// Reads the `ServiceAccount` token from the default location and creates - /// an HTTP client for API requests. The deployment is used to determine if - /// authentication is enabled or disabled by checking for the presence of - /// the `BIND_ALLOWED_SERVICE_ACCOUNTS` environment variable in the bindcar container. + /// Creates an HTTP client (with connect/request timeouts) for API + /// requests. The `ServiceAccount` token is read lazily at request time and + /// cached for [`TOKEN_CACHE_TTL_SECS`] so kubelet token rotation is picked + /// up. The deployment is used to determine if authentication is enabled or + /// disabled by checking for the presence of the + /// `BIND_ALLOWED_SERVICE_ACCOUNTS` environment variable in the bindcar container. /// /// # Arguments /// * `deployment` - The Deployment for the `Bind9Instance` @@ -149,11 +242,9 @@ impl Bind9Manager { instance_name: String, instance_namespace: String, ) -> Self { - let token = Self::read_service_account_token().ok(); - Self { - client: Arc::new(HttpClient::new()), - token: Arc::new(token), + client: Arc::new(build_http_client()), + token_cache: Arc::new(RwLock::new(None)), deployment: Some(deployment), instance_name: Some(instance_name), instance_namespace: Some(instance_namespace), @@ -169,6 +260,9 @@ impl Bind9Manager { /// API-server-audience token ([`SERVICE_ACCOUNT_TOKEN_PATH`]), which is kept /// only as a backward-compatible fallback for clusters that have not yet /// projected the audience-scoped token. + /// + /// Called at request time (via [`Self::get_token`]) rather than once at + /// construction, because the kubelet rotates the projected token file. fn read_service_account_token() -> Result { match std::fs::read_to_string(BINDCAR_TOKEN_PATH) { Ok(token) => Ok(token), @@ -249,10 +343,13 @@ impl Bind9Manager { /// Get the authentication token if available and auth is enabled. /// + /// The token is read from disk at request time and cached for + /// [`TOKEN_CACHE_TTL_SECS`]; a stale cache entry triggers a re-read so + /// kubelet rotation of the projected token file is always picked up. + /// /// Returns `None` if: /// - Auth is disabled for this instance /// - Token file couldn't be read - /// - No token was loaded /// /// This is a public method to allow external code to check auth status and get the token. #[must_use] @@ -261,7 +358,36 @@ impl Bind9Manager { return None; } - self.token.as_ref().clone() + self.cached_or_fresh_token() + } + + /// Return the cached token if still fresh, otherwise re-read it from disk. + /// + /// The negative result (no readable token file) is cached too, so an + /// auth-disabled or out-of-cluster environment does not re-read the file + /// on every request within the TTL. A poisoned lock is treated as a cache + /// miss: the token is re-read from disk and returned even if the cache + /// cannot be updated. + fn cached_or_fresh_token(&self) -> Option { + // Fast path: serve a fresh cached token under the read lock. + if let Ok(guard) = self.token_cache.read() { + if let Some(cached) = guard.as_ref() { + if is_token_cache_fresh(cached.read_at, Instant::now()) { + return cached.token.clone(); + } + } + } + + // Slow path: (re-)read the token file and refresh the cache. + let token = Self::read_service_account_token().ok(); + if let Ok(mut guard) = self.token_cache.write() { + *guard = Some(CachedToken { + token: token.clone(), + read_at: Instant::now(), + }); + } + + token } /// Get a reference to the HTTP client for making API requests. diff --git a/src/bind9/mod_tests.rs b/src/bind9/mod_tests.rs index 58492ad9..4e750b7f 100644 --- a/src/bind9/mod_tests.rs +++ b/src/bind9/mod_tests.rs @@ -39,4 +39,119 @@ mod tests { let debug_output = format!("{manager:?}"); assert!(debug_output.starts_with("Bind9Manager")); } + + // ===================================================== + // ServiceAccount token cache (stale-token fix) + // ===================================================== + + #[test] + fn test_token_cache_fresh_within_ttl() { + let read_at = std::time::Instant::now(); + let now = read_at + std::time::Duration::from_secs(1); + + assert!( + super::super::is_token_cache_fresh(read_at, now), + "a just-read token must be served from cache" + ); + } + + #[test] + fn test_token_cache_stale_after_ttl() { + let read_at = std::time::Instant::now(); + let now = read_at + std::time::Duration::from_secs(super::super::TOKEN_CACHE_TTL_SECS + 1); + + assert!( + !super::super::is_token_cache_fresh(read_at, now), + "a token older than the TTL must be re-read from disk" + ); + } + + #[test] + fn test_token_cache_ttl_well_below_projected_token_expiry() { + // deploy/operator/deployment.yaml projects the bindcar-audience token + // with expirationSeconds: 3600 and kubelet rewrites the file at ~80% + // of that lifetime. The cache TTL must stay well below the rotation + // window so the operator never presents an expired token. + let projected_token_expiry_secs: u64 = 3600; + assert!(super::super::TOKEN_CACHE_TTL_SECS * 2 < projected_token_expiry_secs); + } + + /// Build a Deployment whose bindcar container has auth disabled + /// (no `BIND_ALLOWED_SERVICE_ACCOUNTS` env var). + fn deployment_without_auth_env() -> k8s_openapi::api::apps::v1::Deployment { + use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; + use k8s_openapi::api::core::v1::{Container, PodSpec, PodTemplateSpec}; + + Deployment { + spec: Some(DeploymentSpec { + template: PodTemplateSpec { + spec: Some(PodSpec { + containers: vec![Container { + name: crate::constants::CONTAINER_NAME_BINDCAR.to_string(), + env: None, + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn test_get_token_none_when_auth_disabled() { + ensure_crypto_provider(); + let manager = Bind9Manager::new_with_deployment( + std::sync::Arc::new(deployment_without_auth_env()), + "test-instance".to_string(), + "bindy-system".to_string(), + ); + + assert!(!manager.is_auth_enabled()); + assert_eq!( + manager.get_token(), + None, + "auth-disabled instances must never present a token" + ); + } + + // ===================================================== + // HTTP client timeouts + // ===================================================== + + #[tokio::test] + async fn test_http_client_request_times_out_on_unresponsive_server() { + ensure_crypto_provider(); + + // A server that accepts connections but never responds: without a + // request timeout, this hangs a reconcile task forever. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let mut held_sockets = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + held_sockets.push(stream); // hold the connection open, never reply + } + }); + + let client = super::super::build_http_client_with_timeouts( + std::time::Duration::from_millis(500), + std::time::Duration::from_millis(500), + ); + + let result = client + .get(format!("http://{addr}/api/v1/server/status")) + .send() + .await; + + server.abort(); + + let err = result.expect_err("request to an unresponsive server must fail"); + assert!(err.is_timeout(), "expected a timeout error, got: {err}"); + } } diff --git a/src/bind9/records/a.rs b/src/bind9/records/a.rs index 0e5bc192..da4afa66 100644 --- a/src/bind9/records/a.rs +++ b/src/bind9/records/a.rs @@ -4,7 +4,10 @@ //! A and AAAA record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::{Context, Result}; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,11 +15,9 @@ use hickory_proto::rr::{DNSClass, Name, RData, Record, RecordType}; use std::collections::HashSet; use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; -use tracing::{error, info}; +use tracing::{error, info, warn}; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; - -/// Compare existing DNS `RRset` with desired IPv4 addresses. +/// Compare existing DNS `RRset` with desired IPv4 addresses and TTL. /// /// This function implements declarative reconciliation for A records by comparing /// the current state (existing DNS records) with desired state (spec). @@ -25,12 +26,17 @@ use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; /// /// * `existing_records` - Records currently in DNS (from query) /// * `desired_ips` - IP addresses from `ARecordSpec` +/// * `desired_ttl` - Effective TTL from the spec /// /// # Returns /// /// `true` if existing `RRset` matches desired state exactly (no changes needed), -/// `false` if update required (add/remove IPs needed). -fn compare_ip_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool { +/// `false` if update required (add/remove IPs or TTL change needed). +fn compare_ip_rrset(existing_records: &[Record], desired_ips: &[String], desired_ttl: u32) -> bool { + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let existing_ips: HashSet = existing_records .iter() .filter_map(|record| { @@ -46,33 +52,63 @@ fn compare_ip_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool existing_ips == desired_set } -/// Compare existing DNS `RRset` with desired IPv6 addresses. +/// Compare existing DNS `RRset` with desired IPv6 addresses and TTL. /// /// This function implements declarative reconciliation for AAAA records by comparing /// the current state (existing DNS records) with desired state (spec). /// +/// Both sides are parsed to [`Ipv6Addr`] before comparison so that equivalent +/// textual forms (e.g. `2001:DB8::1`, uncompressed notation) do not cause +/// endless delete/recreate churn. +/// /// # Arguments /// /// * `existing_records` - Records currently in DNS (from query) /// * `desired_ips` - IP addresses from `AAAARecordSpec` +/// * `desired_ttl` - Effective TTL from the spec /// /// # Returns /// /// `true` if existing `RRset` matches desired state exactly (no changes needed), -/// `false` if update required (add/remove IPs needed). -fn compare_ipv6_rrset(existing_records: &[Record], desired_ips: &[String]) -> bool { - let existing_ips: HashSet = existing_records +/// `false` if update required (add/remove IPs or TTL change needed). A desired +/// IP that cannot be parsed is treated as a mismatch (with a warning), never a +/// panic. +fn compare_ipv6_rrset( + existing_records: &[Record], + desired_ips: &[String], + desired_ttl: u32, +) -> bool { + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + + let existing_ips: HashSet = existing_records .iter() .filter_map(|record| { if let RData::AAAA(ipv6) = &record.data { - Some(ipv6.to_string()) + Some(ipv6.0) } else { None } }) .collect(); - let desired_set: HashSet = desired_ips.iter().cloned().collect(); + let mut desired_set: HashSet = HashSet::with_capacity(desired_ips.len()); + for ip_str in desired_ips { + match Ipv6Addr::from_str(ip_str) { + Ok(addr) => { + desired_set.insert(addr); + } + Err(e) => { + warn!( + "Invalid IPv6 address '{}' in desired spec (treating as mismatch): {}", + ip_str, e + ); + return false; + } + } + } + existing_ips == desired_set } @@ -103,13 +139,14 @@ pub async fn add_a_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::A, "A", server, - |existing_records| compare_ip_rrset(existing_records, ipv4_addresses), + |existing_records| compare_ip_rrset(existing_records, ipv4_addresses, ttl_value), ) .await?; @@ -117,9 +154,6 @@ pub async fn add_a_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?; let fqdn = build_record_fqdn(zone_name, name)?; @@ -127,7 +161,7 @@ pub async fn add_a_record( let mut client = build_authenticated_client(server, key_data).await?; // Step 1: delete existing RRset (ignore errors — may not exist). - let delete_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(RecordType::A)); + let delete_record = build_delete_rrset_record(&fqdn, RecordType::A); let _ = client.delete_rrset(delete_record, zone.clone()).await; info!( @@ -197,13 +231,14 @@ pub async fn add_aaaa_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::AAAA, "AAAA", server, - |existing_records| compare_ipv6_rrset(existing_records, ipv6_addresses), + |existing_records| compare_ipv6_rrset(existing_records, ipv6_addresses, ttl_value), ) .await?; @@ -211,16 +246,13 @@ pub async fn add_aaaa_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?; let fqdn = build_record_fqdn(zone_name, name)?; let mut client = build_authenticated_client(server, key_data).await?; - let delete_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(RecordType::AAAA)); + let delete_record = build_delete_rrset_record(&fqdn, RecordType::AAAA); let _ = client.delete_rrset(delete_record, zone.clone()).await; info!( diff --git a/src/bind9/records/a_tests.rs b/src/bind9/records/a_tests.rs index ec74cba8..e2376aad 100644 --- a/src/bind9/records/a_tests.rs +++ b/src/bind9/records/a_tests.rs @@ -5,7 +5,112 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + use hickory_proto::rr::{Name, RData, Record}; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_a_record(ip: Ipv4Addr, ttl: u32) -> Record { + let name = Name::from_str("www.example.com.").expect("valid test name"); + Record::from_rdata(name, ttl, RData::A(ip.into())) + } + + fn make_aaaa_record(ip: Ipv6Addr, ttl: u32) -> Record { + let name = Name::from_str("www.example.com.").expect("valid test name"); + Record::from_rdata(name, ttl, RData::AAAA(ip.into())) + } + + // ========== compare_ip_rrset (A records) ========== + + #[test] + fn test_compare_ip_rrset_matches_same_ips_and_ttl() { + let existing = vec![make_a_record(Ipv4Addr::new(192, 0, 2, 1), TEST_TTL)]; + let desired = vec!["192.0.2.1".to_string()]; + assert!(compare_ip_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ip_rrset_detects_ip_mismatch() { + let existing = vec![make_a_record(Ipv4Addr::new(192, 0, 2, 1), TEST_TTL)]; + let desired = vec!["192.0.2.2".to_string()]; + assert!(!compare_ip_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ip_rrset_ttl_only_change_triggers_update() { + // Same addresses, different TTL: must be reported as a mismatch. + let existing = vec![make_a_record(Ipv4Addr::new(192, 0, 2, 1), TEST_TTL)]; + let desired = vec!["192.0.2.1".to_string()]; + assert!(!compare_ip_rrset(&existing, &desired, OTHER_TTL)); + } + + // ========== compare_ipv6_rrset (AAAA records) ========== + + #[test] + fn test_compare_ipv6_rrset_matches_canonical_form() { + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["2001:db8::1".to_string()]; + assert!(compare_ipv6_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ipv6_rrset_matches_uppercase_spec() { + // `2001:DB8::1` is the same address as `2001:db8::1` and must NOT churn. + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["2001:DB8::1".to_string()]; + assert!(compare_ipv6_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ipv6_rrset_matches_uncompressed_spec() { + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["2001:0db8:0000:0000:0000:0000:0000:0001".to_string()]; + assert!(compare_ipv6_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ipv6_rrset_detects_address_mismatch() { + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["2001:db8::2".to_string()]; + assert!(!compare_ipv6_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ipv6_rrset_unparseable_desired_is_mismatch() { + // Invalid spec address must be treated as a mismatch, not a panic. + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["not-an-ipv6-address".to_string()]; + assert!(!compare_ipv6_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_ipv6_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_aaaa_record( + Ipv6Addr::from_str("2001:db8::1").expect("valid test IP"), + TEST_TTL, + )]; + let desired = vec!["2001:db8::1".to_string()]; + assert!(!compare_ipv6_rrset(&existing, &desired, OTHER_TTL)); + } #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] diff --git a/src/bind9/records/caa.rs b/src/bind9/records/caa.rs index 510be7b2..cac0a37d 100644 --- a/src/bind9/records/caa.rs +++ b/src/bind9/records/caa.rs @@ -4,7 +4,10 @@ //! CAA record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::{Context, Result}; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -13,9 +16,60 @@ use std::str::FromStr; use tracing::info; use url::Url; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Compare existing DNS `RRset` with the desired CAA fields and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `issuer_critical` - Desired issuer-critical flag from the spec +/// * `tag` - Desired CAA tag (`issue`, `issuewild`, or `iodef`) from the spec +/// * `value` - Desired CAA value from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_caa_rrset( + existing_records: &[Record], + issuer_critical: bool, + tag: &str, + value: &str, + desired_ttl: u32, +) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::CAA(existing_caa) = &existing_records[0].data else { + return false; + }; -/// Add a CAA record using dynamic DNS update (RFC 2136). + let flags_match = existing_caa.issuer_critical == issuer_critical; + let tag_match = existing_caa.tag == tag; + + let value_match = match tag { + "issue" | "issuewild" => existing_caa + .value_as_issue() + .ok() + .map(|(name, _opts)| name.map(|n| n.to_string()).unwrap_or_default()) + .is_some_and(|s| s == value), + "iodef" => existing_caa + .value_as_iodef() + .ok() + .is_some_and(|url| url.as_str() == value), + _ => false, + }; + + flags_match && tag_match && value_match +} + +/// Add a CAA record using dynamic DNS update (RFC 2136) with `RRset` synchronization. +/// +/// If the existing `RRset` differs from the desired state, the entire CAA +/// `RRset` for the name is deleted and recreated so stale rdata never lingers. /// /// # Errors /// @@ -36,8 +90,7 @@ pub async fn add_caa_record( key_data: &RndcKeyData, ) -> Result<()> { let issuer_critical = flags != 0; - let tag_for_comparison = tag.to_string(); - let value_for_comparison = value.to_string(); + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, @@ -46,28 +99,7 @@ pub async fn add_caa_record( "CAA", server, |existing_records| { - if existing_records.len() == 1 { - if let RData::CAA(existing_caa) = &existing_records[0].data { - let flags_match = existing_caa.issuer_critical == issuer_critical; - let tag_match = existing_caa.tag == tag_for_comparison; - - let value_match = match tag_for_comparison.as_str() { - "issue" | "issuewild" => existing_caa - .value_as_issue() - .ok() - .map(|(name, _opts)| name.map(|n| n.to_string()).unwrap_or_default()) - .is_some_and(|s| s == value_for_comparison), - "iodef" => existing_caa - .value_as_iodef() - .ok() - .is_some_and(|url| url.as_str() == value_for_comparison), - _ => false, - }; - - return flags_match && tag_match && value_match; - } - } - false + compare_caa_rrset(existing_records, issuer_critical, tag, value, ttl_value) }, ) .await?; @@ -76,9 +108,6 @@ pub async fn add_caa_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name).context(format!("Invalid zone name for CAA: {zone_name}"))?; let fqdn = build_record_fqdn(zone_name, name)?; @@ -111,6 +140,12 @@ pub async fn add_caa_record( record.dns_class = DNSClass::IN; let mut client = build_authenticated_client(server, key_data).await?; + + // Step 1: delete existing RRset (ignore errors — may not exist). + let delete_record = build_delete_rrset_record(&fqdn, RecordType::CAA); + let _ = client.delete_rrset(delete_record, zone.clone()).await; + + // Step 2: append the desired record to create the new RRset. let response = client .append(record, zone, false) .await diff --git a/src/bind9/records/caa_tests.rs b/src/bind9/records/caa_tests.rs index fe8eeeed..bc04c3e7 100644 --- a/src/bind9/records/caa_tests.rs +++ b/src/bind9/records/caa_tests.rs @@ -5,8 +5,76 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_caa_issue_record(issuer_critical: bool, ca_domain: &str, ttl: u32) -> Record { + let name = Name::from_str("example.com.").expect("valid test name"); + let ca_name = Name::from_str(ca_domain).expect("valid test CA domain"); + Record::from_rdata( + name, + ttl, + RData::CAA(rdata::CAA::new_issue( + issuer_critical, + Some(ca_name), + Vec::new(), + )), + ) + } + + // ========== compare_caa_rrset ========== + + #[test] + fn test_compare_caa_rrset_matches_same_values_and_ttl() { + let existing = vec![make_caa_issue_record(false, "letsencrypt.org", TEST_TTL)]; + assert!(compare_caa_rrset( + &existing, + false, + "issue", + "letsencrypt.org", + TEST_TTL + )); + } + + #[test] + fn test_compare_caa_rrset_detects_value_mismatch() { + let existing = vec![make_caa_issue_record(false, "letsencrypt.org", TEST_TTL)]; + assert!(!compare_caa_rrset( + &existing, + false, + "issue", + "digicert.com", + TEST_TTL + )); + } + + #[test] + fn test_compare_caa_rrset_detects_flags_mismatch() { + let existing = vec![make_caa_issue_record(false, "letsencrypt.org", TEST_TTL)]; + assert!(!compare_caa_rrset( + &existing, + true, + "issue", + "letsencrypt.org", + TEST_TTL + )); + } + + #[test] + fn test_compare_caa_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_caa_issue_record(false, "letsencrypt.org", TEST_TTL)]; + assert!(!compare_caa_rrset( + &existing, + false, + "issue", + "letsencrypt.org", + OTHER_TTL + )); + } + #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] async fn test_add_caa_record_placeholder() { diff --git a/src/bind9/records/cname.rs b/src/bind9/records/cname.rs index 53047db1..ab99283b 100644 --- a/src/bind9/records/cname.rs +++ b/src/bind9/records/cname.rs @@ -4,7 +4,10 @@ //! CNAME record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_record_fqdn, effective_record_ttl, rrset_ttl_matches, + should_update_record, +}; use anyhow::Result; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,7 +15,30 @@ use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType}; use std::str::FromStr; use tracing::info; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Compare existing DNS `RRset` with the desired CNAME target and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `target` - Desired CNAME target from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_cname_rrset(existing_records: &[Record], target: &str, desired_ttl: u32) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::CNAME(existing_cname) = &existing_records[0].data else { + return false; + }; + existing_cname.0.to_string() == target +} /// Add a CNAME record using dynamic DNS update (RFC 2136). /// @@ -28,21 +54,14 @@ pub async fn add_cname_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - let target_for_comparison = target.to_string(); + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::CNAME, "CNAME", server, - |existing_records| { - if existing_records.len() == 1 { - if let RData::CNAME(existing_cname) = &existing_records[0].data { - return existing_cname.0.to_string() == target_for_comparison; - } - } - false - }, + |existing_records| compare_cname_rrset(existing_records, target, ttl_value), ) .await?; @@ -50,9 +69,6 @@ pub async fn add_cname_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name)?; let fqdn = build_record_fqdn(zone_name, name)?; let target_name = Name::from_str(target)?; diff --git a/src/bind9/records/cname_tests.rs b/src/bind9/records/cname_tests.rs index 4b73f924..b2d24218 100644 --- a/src/bind9/records/cname_tests.rs +++ b/src/bind9/records/cname_tests.rs @@ -5,7 +5,50 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + use hickory_proto::rr::rdata; + + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_cname_record(target: &str, ttl: u32) -> Record { + let name = Name::from_str("alias.example.com.").expect("valid test name"); + let target_name = Name::from_str(target).expect("valid test target"); + Record::from_rdata(name, ttl, RData::CNAME(rdata::CNAME(target_name))) + } + + // ========== compare_cname_rrset ========== + + #[test] + fn test_compare_cname_rrset_matches_same_target_and_ttl() { + let existing = vec![make_cname_record("target.example.com.", TEST_TTL)]; + assert!(compare_cname_rrset( + &existing, + "target.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_cname_rrset_detects_target_mismatch() { + let existing = vec![make_cname_record("target.example.com.", TEST_TTL)]; + assert!(!compare_cname_rrset( + &existing, + "other.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_cname_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_cname_record("target.example.com.", TEST_TTL)]; + assert!(!compare_cname_rrset( + &existing, + "target.example.com.", + OTHER_TTL + )); + } #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] diff --git a/src/bind9/records/mod.rs b/src/bind9/records/mod.rs index dd5fba85..2291d729 100644 --- a/src/bind9/records/mod.rs +++ b/src/bind9/records/mod.rs @@ -26,6 +26,56 @@ use tracing::{info, warn}; use crate::bind9::rndc::create_tsig_signer; use crate::bind9::types::RndcKeyData; +use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; + +/// Fallback TTL (seconds) used only if [`DEFAULT_DNS_RECORD_TTL_SECS`] cannot be +/// converted to `u32`. +const FALLBACK_DNS_RECORD_TTL_SECS: u32 = 300; + +/// Resolve the effective TTL for a DNS record from an optional spec value. +/// +/// Returns the spec TTL when present and representable as `u32`; otherwise falls +/// back to [`DEFAULT_DNS_RECORD_TTL_SECS`]. +/// +/// This is the single source of truth for the TTL written to DNS, and is also +/// used when diffing desired state against existing records so that TTL-only +/// spec changes trigger an update. +pub(crate) fn effective_record_ttl(ttl: Option) -> u32 { + u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)).unwrap_or_else(|_| { + u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(FALLBACK_DNS_RECORD_TTL_SECS) + }) +} + +/// Check whether every record in an existing `RRset` carries the desired TTL. +/// +/// Used by the per-record-type compare functions so that a TTL-only spec change +/// is detected as a mismatch and triggers the update path. +pub(crate) fn rrset_ttl_matches(existing_records: &[Record], desired_ttl: u32) -> bool { + existing_records + .iter() + .all(|record| record.ttl == desired_ttl) +} + +/// Build the placeholder record used by RFC 2136 "delete `RRset`" operations. +/// +/// `delete_rrset()` overwrites class/TTL/data based on `record_type`, so the +/// returned record only needs to carry the FQDN and the record type. +pub(crate) fn build_delete_rrset_record(fqdn: &Name, record_type: RecordType) -> Record { + Record::from_rdata(fqdn.clone(), 0, RData::Update0(record_type)) +} + +/// Response codes that indicate an RFC 2136 delete succeeded (`NoError`) or the +/// target records already did not exist (`NXDomain`, `NXRRSet`) — idempotent +/// success. +/// +/// Every other code (e.g. `Refused`, `NotAuth`, `NotZone`, `ServFail`) is a real +/// failure and must be surfaced to the caller. +pub(crate) fn is_idempotent_delete_response_code(code: ResponseCode) -> bool { + matches!( + code, + ResponseCode::NoError | ResponseCode::NXDomain | ResponseCode::NXRRSet + ) +} /// Build an unauthenticated UDP DNS client for read-only queries. async fn build_query_client(server_str: &str) -> Result> { @@ -192,11 +242,14 @@ where /// /// # Returns /// -/// Returns `Ok(())` if deletion succeeded (or if record didn't exist). +/// Returns `Ok(())` if deletion succeeded (`NoError`) or the record already did +/// not exist (`NXDomain`/`NXRRSet` — idempotent success). /// /// # Errors /// -/// Returns an error if the DNS server rejects the update or connection fails. +/// Returns an error if the connection fails or the DNS server rejects the +/// update with any other response code (e.g. `Refused`, `NotAuth`, `NotZone`, +/// `ServFail`), so TSIG/ACL failures are never silently swallowed. pub async fn delete_dns_record( zone_name: &str, name: &str, @@ -215,7 +268,7 @@ pub async fn delete_dns_record( ); // Build a placeholder record. delete_rrset() overwrites class/ttl/data based on record_type. - let dummy_record = Record::from_rdata(fqdn.clone(), 0, RData::Update0(record_type)); + let dummy_record = build_delete_rrset_record(&fqdn, record_type); let response = client .delete_rrset(dummy_record, zone) @@ -224,23 +277,28 @@ pub async fn delete_dns_record( format!("Failed to send DNS UPDATE to delete {record_type:?} record {fqdn}") })?; - match response.metadata.response_code { - ResponseCode::NoError => { - info!( - "Successfully deleted {:?} record: {} from zone {}", - record_type, name, zone_name - ); - Ok(()) - } - code => { - warn!( - "DNS DELETE for {:?} record {fqdn} returned code: {:?} (may not have existed)", - record_type, code - ); - // Deletion is idempotent: don't treat "not found" as error. - Ok(()) - } + let code = response.metadata.response_code; + if !is_idempotent_delete_response_code(code) { + return Err(anyhow::anyhow!( + "DNS DELETE for {record_type:?} record {fqdn} in zone {zone_name} \ + rejected with response code: {code:?}" + )); + } + + if code == ResponseCode::NoError { + info!( + "Successfully deleted {:?} record: {} from zone {}", + record_type, name, zone_name + ); + return Ok(()); } + + // NXDomain/NXRRSet: the record did not exist — deletion is idempotent. + warn!( + "DNS DELETE for {:?} record {fqdn} returned code: {:?} (record did not exist)", + record_type, code + ); + Ok(()) } #[cfg(test)] diff --git a/src/bind9/records/mod_tests.rs b/src/bind9/records/mod_tests.rs index ad205042..8bbd91d3 100644 --- a/src/bind9/records/mod_tests.rs +++ b/src/bind9/records/mod_tests.rs @@ -8,7 +8,102 @@ #[cfg(test)] mod tests { + use super::super::*; use hickory_proto::rr::RecordType; + use std::net::Ipv4Addr; + + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_a_record(ttl: u32) -> Record { + let name = Name::from_str("www.example.com.").expect("valid test name"); + Record::from_rdata(name, ttl, RData::A(Ipv4Addr::new(192, 0, 2, 1).into())) + } + + // ========== Tests for is_idempotent_delete_response_code() ========== + + #[test] + fn test_is_idempotent_delete_response_code_no_error() { + assert!(is_idempotent_delete_response_code(ResponseCode::NoError)); + } + + #[test] + fn test_is_idempotent_delete_response_code_nxdomain() { + assert!(is_idempotent_delete_response_code(ResponseCode::NXDomain)); + } + + #[test] + fn test_is_idempotent_delete_response_code_nxrrset() { + assert!(is_idempotent_delete_response_code(ResponseCode::NXRRSet)); + } + + #[test] + fn test_is_idempotent_delete_response_code_rejects_failures() { + let failure_codes = [ + ResponseCode::Refused, + ResponseCode::NotAuth, + ResponseCode::NotZone, + ResponseCode::ServFail, + ResponseCode::FormErr, + ResponseCode::NotImp, + ResponseCode::YXRRSet, + ]; + for code in failure_codes { + assert!( + !is_idempotent_delete_response_code(code), + "{code:?} must NOT be treated as idempotent delete success" + ); + } + } + + // ========== Tests for effective_record_ttl() ========== + + #[test] + fn test_effective_record_ttl_uses_default_when_none() { + assert_eq!(effective_record_ttl(None), TEST_TTL); + } + + #[test] + fn test_effective_record_ttl_uses_spec_value() { + assert_eq!(effective_record_ttl(Some(3600)), 3600); + } + + #[test] + fn test_effective_record_ttl_falls_back_on_negative_value() { + assert_eq!(effective_record_ttl(Some(-5)), TEST_TTL); + } + + // ========== Tests for rrset_ttl_matches() ========== + + #[test] + fn test_rrset_ttl_matches_when_all_equal() { + let records = vec![make_a_record(TEST_TTL), make_a_record(TEST_TTL)]; + assert!(rrset_ttl_matches(&records, TEST_TTL)); + } + + #[test] + fn test_rrset_ttl_matches_detects_mismatch() { + let records = vec![make_a_record(TEST_TTL), make_a_record(OTHER_TTL)]; + assert!(!rrset_ttl_matches(&records, TEST_TTL)); + } + + #[test] + fn test_rrset_ttl_matches_ttl_only_change() { + // Simulates a user changing spec.ttl with unchanged rdata: must report mismatch. + let records = vec![make_a_record(TEST_TTL)]; + assert!(!rrset_ttl_matches(&records, OTHER_TTL)); + } + + // ========== Tests for build_delete_rrset_record() ========== + + #[test] + fn test_build_delete_rrset_record_shape() { + let fqdn = Name::from_str("mail.example.com.").expect("valid test name"); + let record = build_delete_rrset_record(&fqdn, RecordType::MX); + assert_eq!(record.name, fqdn); + assert_eq!(record.ttl, 0); + assert_eq!(record.data, RData::Update0(RecordType::MX)); + } // ========== Tests for should_update_record() logic ========== diff --git a/src/bind9/records/mx.rs b/src/bind9/records/mx.rs index 34331e56..583af3bd 100644 --- a/src/bind9/records/mx.rs +++ b/src/bind9/records/mx.rs @@ -4,7 +4,10 @@ //! MX record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::Result; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,9 +15,45 @@ use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType}; use std::str::FromStr; use tracing::info; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Default MX preference used when the spec priority cannot be represented as `u16`. +const DEFAULT_MX_PREFERENCE: u16 = 10; -/// Add an MX record using dynamic DNS update (RFC 2136). +/// Compare existing DNS `RRset` with the desired MX preference, exchange, and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `preference` - Desired MX preference (priority) from the spec +/// * `mail_server` - Desired mail exchange host from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_mx_rrset( + existing_records: &[Record], + preference: u16, + mail_server: &str, + desired_ttl: u32, +) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::MX(existing_mx) = &existing_records[0].data else { + return false; + }; + existing_mx.preference == preference && existing_mx.exchange.to_string() == mail_server +} + +/// Add an MX record using dynamic DNS update (RFC 2136) with `RRset` synchronization. +/// +/// If the existing `RRset` differs from the desired state, the entire MX +/// `RRset` for the name is deleted and recreated so stale rdata (e.g. an old +/// mail server) never lingers. /// /// # Errors /// @@ -29,23 +68,15 @@ pub async fn add_mx_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - let mail_server_for_comparison = mail_server.to_string(); - let priority_u16 = u16::try_from(priority).unwrap_or(10); + let priority_u16 = u16::try_from(priority).unwrap_or(DEFAULT_MX_PREFERENCE); + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::MX, "MX", server, - |existing_records| { - if existing_records.len() == 1 { - if let RData::MX(existing_mx) = &existing_records[0].data { - return existing_mx.preference == priority_u16 - && existing_mx.exchange.to_string() == mail_server_for_comparison; - } - } - false - }, + |existing_records| compare_mx_rrset(existing_records, priority_u16, mail_server, ttl_value), ) .await?; @@ -53,9 +84,6 @@ pub async fn add_mx_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name)?; let fqdn = build_record_fqdn(zone_name, name)?; let mx_name = Name::from_str(mail_server)?; @@ -73,6 +101,12 @@ pub async fn add_mx_record( ); let mut client = build_authenticated_client(server, key_data).await?; + + // Step 1: delete existing RRset (ignore errors — may not exist). + let delete_record = build_delete_rrset_record(&fqdn, RecordType::MX); + let _ = client.delete_rrset(delete_record, zone.clone()).await; + + // Step 2: append the desired record to create the new RRset. let response = client.append(record, zone, false).await?; match response.metadata.response_code { diff --git a/src/bind9/records/mx_tests.rs b/src/bind9/records/mx_tests.rs index 412c140b..fe10cae7 100644 --- a/src/bind9/records/mx_tests.rs +++ b/src/bind9/records/mx_tests.rs @@ -5,7 +5,70 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + use hickory_proto::rr::rdata; + + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + const TEST_PRIORITY: u16 = 10; + const OTHER_PRIORITY: u16 = 20; + + fn make_mx_record(priority: u16, mail_server: &str, ttl: u32) -> Record { + let name = Name::from_str("example.com.").expect("valid test name"); + let exchange = Name::from_str(mail_server).expect("valid test exchange"); + Record::from_rdata(name, ttl, RData::MX(rdata::MX::new(priority, exchange))) + } + + // ========== compare_mx_rrset ========== + + #[test] + fn test_compare_mx_rrset_matches_same_values_and_ttl() { + let existing = vec![make_mx_record(TEST_PRIORITY, "mail.example.com.", TEST_TTL)]; + assert!(compare_mx_rrset( + &existing, + TEST_PRIORITY, + "mail.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_mx_rrset_detects_mail_server_mismatch() { + let existing = vec![make_mx_record( + TEST_PRIORITY, + "mail1.example.com.", + TEST_TTL, + )]; + assert!(!compare_mx_rrset( + &existing, + TEST_PRIORITY, + "mail2.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_mx_rrset_detects_priority_mismatch() { + let existing = vec![make_mx_record(TEST_PRIORITY, "mail.example.com.", TEST_TTL)]; + assert!(!compare_mx_rrset( + &existing, + OTHER_PRIORITY, + "mail.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_mx_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_mx_record(TEST_PRIORITY, "mail.example.com.", TEST_TTL)]; + assert!(!compare_mx_rrset( + &existing, + TEST_PRIORITY, + "mail.example.com.", + OTHER_TTL + )); + } #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] diff --git a/src/bind9/records/ns.rs b/src/bind9/records/ns.rs index e14129e8..81cb2073 100644 --- a/src/bind9/records/ns.rs +++ b/src/bind9/records/ns.rs @@ -4,7 +4,10 @@ //! NS record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::Result; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,9 +15,36 @@ use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType}; use std::str::FromStr; use tracing::info; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Compare existing DNS `RRset` with the desired nameserver and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `nameserver` - Desired nameserver host from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_ns_rrset(existing_records: &[Record], nameserver: &str, desired_ttl: u32) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::NS(existing_ns) = &existing_records[0].data else { + return false; + }; + existing_ns.0.to_string() == nameserver +} -/// Add an NS record using dynamic DNS update (RFC 2136). +/// Add an NS record using dynamic DNS update (RFC 2136) with `RRset` synchronization. +/// +/// NS records managed here are used for delegations. If the existing `RRset` +/// differs from the desired state, the entire NS `RRset` for the name is +/// deleted and recreated so stale delegation rdata never lingers. /// /// # Errors /// @@ -28,21 +58,14 @@ pub async fn add_ns_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - let nameserver_for_comparison = nameserver.to_string(); + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::NS, "NS", server, - |existing_records| { - if existing_records.len() == 1 { - if let RData::NS(existing_ns) = &existing_records[0].data { - return existing_ns.0.to_string() == nameserver_for_comparison; - } - } - false - }, + |existing_records| compare_ns_rrset(existing_records, nameserver, ttl_value), ) .await?; @@ -50,9 +73,6 @@ pub async fn add_ns_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name)?; let fqdn = build_record_fqdn(zone_name, name)?; let ns_name = Name::from_str(nameserver)?; @@ -66,6 +86,12 @@ pub async fn add_ns_record( ); let mut client = build_authenticated_client(server, key_data).await?; + + // Step 1: delete existing RRset (ignore errors — may not exist). + let delete_record = build_delete_rrset_record(&fqdn, RecordType::NS); + let _ = client.delete_rrset(delete_record, zone.clone()).await; + + // Step 2: append the desired record to create the new RRset. let response = client.append(record, zone, false).await?; match response.metadata.response_code { diff --git a/src/bind9/records/ns_tests.rs b/src/bind9/records/ns_tests.rs index e8335ada..90bc1b4e 100644 --- a/src/bind9/records/ns_tests.rs +++ b/src/bind9/records/ns_tests.rs @@ -5,7 +5,38 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + use hickory_proto::rr::rdata; + + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_ns_record(nameserver: &str, ttl: u32) -> Record { + let name = Name::from_str("sub.example.com.").expect("valid test name"); + let ns_name = Name::from_str(nameserver).expect("valid test nameserver"); + Record::from_rdata(name, ttl, RData::NS(rdata::NS(ns_name))) + } + + // ========== compare_ns_rrset ========== + + #[test] + fn test_compare_ns_rrset_matches_same_nameserver_and_ttl() { + let existing = vec![make_ns_record("ns1.example.com.", TEST_TTL)]; + assert!(compare_ns_rrset(&existing, "ns1.example.com.", TEST_TTL)); + } + + #[test] + fn test_compare_ns_rrset_detects_nameserver_mismatch() { + let existing = vec![make_ns_record("ns1.example.com.", TEST_TTL)]; + assert!(!compare_ns_rrset(&existing, "ns2.example.com.", TEST_TTL)); + } + + #[test] + fn test_compare_ns_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_ns_record("ns1.example.com.", TEST_TTL)]; + assert!(!compare_ns_rrset(&existing, "ns1.example.com.", OTHER_TTL)); + } #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] diff --git a/src/bind9/records/srv.rs b/src/bind9/records/srv.rs index 424ae707..8b0008ac 100644 --- a/src/bind9/records/srv.rs +++ b/src/bind9/records/srv.rs @@ -4,7 +4,10 @@ //! SRV record management. use super::super::types::{RndcKeyData, SRVRecordData}; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::{Context, Result}; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,9 +15,48 @@ use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType}; use std::str::FromStr; use tracing::info; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Compare existing DNS `RRset` with the desired SRV fields and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `priority` - Desired SRV priority from the spec +/// * `weight` - Desired SRV weight from the spec +/// * `port` - Desired SRV port from the spec +/// * `target` - Desired SRV target host from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_srv_rrset( + existing_records: &[Record], + priority: u16, + weight: u16, + port: u16, + target: &str, + desired_ttl: u32, +) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::SRV(existing_srv) = &existing_records[0].data else { + return false; + }; + existing_srv.priority == priority + && existing_srv.weight == weight + && existing_srv.port == port + && existing_srv.target.to_string() == target +} -/// Add an SRV record using dynamic DNS update (RFC 2136). +/// Add an SRV record using dynamic DNS update (RFC 2136) with `RRset` synchronization. +/// +/// If the existing `RRset` differs from the desired state, the entire SRV +/// `RRset` for the name is deleted and recreated so stale rdata never lingers. /// /// # Errors /// @@ -31,13 +73,13 @@ pub async fn add_srv_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - let srv_data_for_comparison = srv_data.clone(); let priority_u16 = u16::try_from(srv_data.priority) .context(format!("Invalid SRV priority: {}", srv_data.priority))?; let weight_u16 = u16::try_from(srv_data.weight) .context(format!("Invalid SRV weight: {}", srv_data.weight))?; let port_u16 = u16::try_from(srv_data.port).context(format!("Invalid SRV port: {}", srv_data.port))?; + let ttl_value = effective_record_ttl(srv_data.ttl); let should_update = should_update_record( zone_name, @@ -46,20 +88,14 @@ pub async fn add_srv_record( "SRV", server, |existing_records| { - if existing_records.len() == 1 { - if let RData::SRV(existing_srv) = &existing_records[0].data { - let priority_match = existing_srv.priority - == u16::try_from(srv_data_for_comparison.priority).unwrap_or(0); - let weight_match = existing_srv.weight - == u16::try_from(srv_data_for_comparison.weight).unwrap_or(0); - let port_match = existing_srv.port - == u16::try_from(srv_data_for_comparison.port).unwrap_or(0); - let target_match = - existing_srv.target.to_string() == srv_data_for_comparison.target; - return priority_match && weight_match && port_match && target_match; - } - } - false + compare_srv_rrset( + existing_records, + priority_u16, + weight_u16, + port_u16, + &srv_data.target, + ttl_value, + ) }, ) .await?; @@ -68,9 +104,6 @@ pub async fn add_srv_record( return Ok(()); } - let ttl_value = u32::try_from(srv_data.ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name).context(format!("Invalid zone name for SRV: {zone_name}"))?; let fqdn = build_record_fqdn(zone_name, name)?; @@ -84,6 +117,12 @@ pub async fn add_srv_record( record.dns_class = DNSClass::IN; let mut client = build_authenticated_client(server, key_data).await?; + + // Step 1: delete existing RRset (ignore errors — may not exist). + let delete_record = build_delete_rrset_record(&fqdn, RecordType::SRV); + let _ = client.delete_rrset(delete_record, zone.clone()).await; + + // Step 2: append the desired record to create the new RRset. let response = client .append(record, zone, false) .await diff --git a/src/bind9/records/srv_tests.rs b/src/bind9/records/srv_tests.rs index f84fe68b..cc3874ec 100644 --- a/src/bind9/records/srv_tests.rs +++ b/src/bind9/records/srv_tests.rs @@ -5,8 +5,104 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData, SRVRecordData}; + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + const TEST_PRIORITY: u16 = 10; + const TEST_WEIGHT: u16 = 60; + const TEST_PORT: u16 = 5060; + const OTHER_PORT: u16 = 5061; + + fn make_srv_record(priority: u16, weight: u16, port: u16, target: &str, ttl: u32) -> Record { + let name = Name::from_str("_sip._tcp.example.com.").expect("valid test name"); + let target_name = Name::from_str(target).expect("valid test target"); + Record::from_rdata( + name, + ttl, + RData::SRV(rdata::SRV::new(priority, weight, port, target_name)), + ) + } + + // ========== compare_srv_rrset ========== + + #[test] + fn test_compare_srv_rrset_matches_same_values_and_ttl() { + let existing = vec![make_srv_record( + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip.example.com.", + TEST_TTL, + )]; + assert!(compare_srv_rrset( + &existing, + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_srv_rrset_detects_port_mismatch() { + let existing = vec![make_srv_record( + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip.example.com.", + TEST_TTL, + )]; + assert!(!compare_srv_rrset( + &existing, + TEST_PRIORITY, + TEST_WEIGHT, + OTHER_PORT, + "sip.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_srv_rrset_detects_target_mismatch() { + let existing = vec![make_srv_record( + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip1.example.com.", + TEST_TTL, + )]; + assert!(!compare_srv_rrset( + &existing, + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip2.example.com.", + TEST_TTL + )); + } + + #[test] + fn test_compare_srv_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_srv_record( + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip.example.com.", + TEST_TTL, + )]; + assert!(!compare_srv_rrset( + &existing, + TEST_PRIORITY, + TEST_WEIGHT, + TEST_PORT, + "sip.example.com.", + OTHER_TTL + )); + } + #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] async fn test_add_srv_record_placeholder() { diff --git a/src/bind9/records/txt.rs b/src/bind9/records/txt.rs index 76e61d26..0a166a02 100644 --- a/src/bind9/records/txt.rs +++ b/src/bind9/records/txt.rs @@ -4,7 +4,10 @@ //! TXT record management. use super::super::types::RndcKeyData; -use super::{build_authenticated_client, build_record_fqdn, should_update_record}; +use super::{ + build_authenticated_client, build_delete_rrset_record, build_record_fqdn, effective_record_ttl, + rrset_ttl_matches, should_update_record, +}; use anyhow::Result; use hickory_net::client::ClientHandle; use hickory_proto::op::ResponseCode; @@ -12,9 +15,40 @@ use hickory_proto::rr::{rdata, DNSClass, Name, RData, Record, RecordType}; use std::str::FromStr; use tracing::info; -use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS; +/// Compare existing DNS `RRset` with the desired TXT strings and TTL. +/// +/// # Arguments +/// +/// * `existing_records` - Records currently in DNS (from query) +/// * `texts` - Desired TXT strings from the spec +/// * `desired_ttl` - Effective TTL from the spec +/// +/// # Returns +/// +/// `true` if the existing `RRset` matches the desired state exactly (no changes +/// needed), `false` if an update is required (rdata or TTL differ). +fn compare_txt_rrset(existing_records: &[Record], texts: &[String], desired_ttl: u32) -> bool { + if existing_records.len() != 1 { + return false; + } + if !rrset_ttl_matches(existing_records, desired_ttl) { + return false; + } + let RData::TXT(existing_txt) = &existing_records[0].data else { + return false; + }; + let existing_texts: Vec = existing_txt + .txt_data + .iter() + .map(|bytes| String::from_utf8_lossy(bytes).to_string()) + .collect(); + existing_texts == texts +} -/// Add a TXT record using dynamic DNS update (RFC 2136). +/// Add a TXT record using dynamic DNS update (RFC 2136) with `RRset` synchronization. +/// +/// If the existing `RRset` differs from the desired state, the entire TXT +/// `RRset` for the name is deleted and recreated so stale rdata never lingers. /// /// # Errors /// @@ -28,26 +62,14 @@ pub async fn add_txt_record( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - let texts_for_comparison = texts.to_vec(); + let ttl_value = effective_record_ttl(ttl); let should_update = should_update_record( zone_name, name, RecordType::TXT, "TXT", server, - |existing_records| { - if existing_records.len() == 1 { - if let RData::TXT(existing_txt) = &existing_records[0].data { - let existing_texts: Vec = existing_txt - .txt_data - .iter() - .map(|bytes| String::from_utf8_lossy(bytes).to_string()) - .collect(); - return existing_texts == texts_for_comparison; - } - } - false - }, + |existing_records| compare_txt_rrset(existing_records, texts, ttl_value), ) .await?; @@ -55,9 +77,6 @@ pub async fn add_txt_record( return Ok(()); } - let ttl_value = u32::try_from(ttl.unwrap_or(DEFAULT_DNS_RECORD_TTL_SECS)) - .unwrap_or(u32::try_from(DEFAULT_DNS_RECORD_TTL_SECS).unwrap_or(300)); - let zone = Name::from_str(zone_name)?; let fqdn = build_record_fqdn(zone_name, name)?; @@ -74,6 +93,12 @@ pub async fn add_txt_record( ); let mut client = build_authenticated_client(server, key_data).await?; + + // Step 1: delete existing RRset (ignore errors — may not exist). + let delete_record = build_delete_rrset_record(&fqdn, RecordType::TXT); + let _ = client.delete_rrset(delete_record, zone.clone()).await; + + // Step 2: append the desired record to create the new RRset. let response = client.append(record, zone, false).await?; match response.metadata.response_code { diff --git a/src/bind9/records/txt_tests.rs b/src/bind9/records/txt_tests.rs index 3be1a290..74745bad 100644 --- a/src/bind9/records/txt_tests.rs +++ b/src/bind9/records/txt_tests.rs @@ -5,8 +5,41 @@ #[cfg(test)] mod tests { + use super::super::*; use crate::bind9::{Bind9Manager, RndcKeyData}; + const TEST_TTL: u32 = 300; + const OTHER_TTL: u32 = 600; + + fn make_txt_record(texts: &[&str], ttl: u32) -> Record { + let name = Name::from_str("example.com.").expect("valid test name"); + let txt_data: Vec = texts.iter().map(ToString::to_string).collect(); + Record::from_rdata(name, ttl, RData::TXT(rdata::TXT::new(txt_data))) + } + + // ========== compare_txt_rrset ========== + + #[test] + fn test_compare_txt_rrset_matches_same_texts_and_ttl() { + let existing = vec![make_txt_record(&["v=spf1 mx ~all"], TEST_TTL)]; + let desired = vec!["v=spf1 mx ~all".to_string()]; + assert!(compare_txt_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_txt_rrset_detects_text_mismatch() { + let existing = vec![make_txt_record(&["v=spf1 mx ~all"], TEST_TTL)]; + let desired = vec!["v=spf1 -all".to_string()]; + assert!(!compare_txt_rrset(&existing, &desired, TEST_TTL)); + } + + #[test] + fn test_compare_txt_rrset_ttl_only_change_triggers_update() { + let existing = vec![make_txt_record(&["v=spf1 mx ~all"], TEST_TTL)]; + let desired = vec!["v=spf1 mx ~all".to_string()]; + assert!(!compare_txt_rrset(&existing, &desired, OTHER_TTL)); + } + #[tokio::test] #[ignore = "Requires running BIND9 server with TSIG key configured for dynamic DNS updates"] async fn test_add_txt_record_placeholder() { diff --git a/src/bind9/rndc.rs b/src/bind9/rndc.rs index 6642c087..7e783b1c 100644 --- a/src/bind9/rndc.rs +++ b/src/bind9/rndc.rs @@ -111,8 +111,45 @@ pub fn parse_rndc_secret_data(data: &BTreeMap>) -> Result String { + let mut stripped = String::with_capacity(content.len()); + let mut rest = content; + + while let Some(start) = rest.find(BLOCK_COMMENT_START) { + stripped.push_str(&rest[..start]); + let after_start = &rest[start + BLOCK_COMMENT_START.len()..]; + let Some(end) = after_start.find(BLOCK_COMMENT_END) else { + // Unterminated block comment: discard the remainder + return stripped; + }; + rest = &after_start[end + BLOCK_COMMENT_END.len()..]; + } + + stripped.push_str(rest); + stripped +} + +/// Returns `true` if a trimmed line is a `#` or `//` line comment. +fn is_line_comment(line: &str) -> bool { + line.starts_with('#') || line.starts_with("//") +} + /// Parse a BIND9 key file (rndc.key format) to extract key metadata. /// +/// Comment lines (`#`, `//`) and `/* ... */` block comments are ignored, so a +/// leading comment such as `# rndc key "docs-example"` cannot poison the +/// parsed key name (which would cause TSIG NOTAUTH on every update). +/// /// Expected format: /// ```text /// key "key-name" { @@ -125,23 +162,29 @@ pub fn parse_rndc_secret_data(data: &BTreeMap>) -> Result Result { - // Simple regex-based parser for BIND9 key file format + // Simple line-based parser for BIND9 key file format // Format: key "name" { algorithm algo; secret "secret"; }; - - // Extract key name - let name = content + let content = strip_block_comments(content); + let statement_lines: Vec<&str> = content .lines() - .find(|line| line.contains("key")) + .map(str::trim) + .filter(|line| !line.is_empty() && !is_line_comment(line)) + .collect(); + + // Extract key name from the `key "name" {` statement + let name = statement_lines + .iter() + .find(|line| line.starts_with("key ") || line.starts_with("key\"")) .and_then(|line| { line.split('"').nth(1) // Get the text between first pair of quotes }) .context("Failed to parse key name from rndc.key file")? .to_string(); - // Extract algorithm - let algorithm_str = content - .lines() - .find(|line| line.contains("algorithm")) + // Extract algorithm from the `algorithm ;` statement + let algorithm_str = statement_lines + .iter() + .find(|line| line.starts_with("algorithm")) .and_then(|line| { line.split_whitespace() .nth(1) // After "algorithm" @@ -162,10 +205,10 @@ fn parse_rndc_key_file(content: &str) -> Result { _ => anyhow::bail!("Unsupported algorithm '{algorithm_str}' in rndc.key file"), }; - // Extract secret - let secret = content - .lines() - .find(|line| line.contains("secret")) + // Extract secret from the `secret "...";` statement + let secret = statement_lines + .iter() + .find(|line| line.starts_with("secret")) .and_then(|line| { line.split('"').nth(1) // Get the text between first pair of quotes }) diff --git a/src/bind9/rndc_tests.rs b/src/bind9/rndc_tests.rs index 54ee4163..6bcccae7 100644 --- a/src/bind9/rndc_tests.rs +++ b/src/bind9/rndc_tests.rs @@ -803,4 +803,107 @@ mod tests { // Rotation at exactly current time should be considered due assert!(crate::bind9::rndc::is_rotation_due(Some(now), now)); } + + // ===================================================== + // rndc.key file comment handling + // ===================================================== + + #[test] + fn test_parse_rndc_key_file_ignores_hash_comment_lines() { + // A leading `#` comment that mentions `key "..."` must not poison the + // parsed key name (a poisoned name causes TSIG NOTAUTH on every update). + let rndc_key_content = r#"# rndc key "docs-example" +# secret "ZmFrZXNlY3JldA==" is only documented here +key "real-key" { + algorithm hmac-sha256; + secret "dGVzdHNlY3JldA=="; +}; +"#; + let mut data = BTreeMap::new(); + data.insert("rndc.key".to_string(), rndc_key_content.as_bytes().to_vec()); + + let key = Bind9Manager::parse_rndc_secret_data(&data).unwrap(); + + assert_eq!(key.name, "real-key"); + assert_eq!(key.algorithm, crate::crd::RndcAlgorithm::HmacSha256); + assert_eq!(key.secret, "dGVzdHNlY3JldA=="); + } + + #[test] + fn test_parse_rndc_key_file_ignores_slash_comment_lines() { + let rndc_key_content = r#"// key "commented-out" { +// algorithm hmac-sha512; +// secret "ZmFrZXNlY3JldA=="; +// }; +key "slash-key" { + algorithm hmac-sha256; + secret "dGVzdHNlY3JldA=="; +}; +"#; + let mut data = BTreeMap::new(); + data.insert("rndc.key".to_string(), rndc_key_content.as_bytes().to_vec()); + + let key = Bind9Manager::parse_rndc_secret_data(&data).unwrap(); + + assert_eq!(key.name, "slash-key"); + assert_eq!(key.algorithm, crate::crd::RndcAlgorithm::HmacSha256); + assert_eq!(key.secret, "dGVzdHNlY3JldA=="); + } + + #[test] + fn test_parse_rndc_key_file_ignores_block_comments() { + let rndc_key_content = r#"/* + * key "block-commented" { + * algorithm hmac-sha384; + * secret "ZmFrZXNlY3JldA=="; + * }; + */ +key "block-key" { + algorithm hmac-sha256; + secret "dGVzdHNlY3JldA=="; +}; +"#; + let mut data = BTreeMap::new(); + data.insert("rndc.key".to_string(), rndc_key_content.as_bytes().to_vec()); + + let key = Bind9Manager::parse_rndc_secret_data(&data).unwrap(); + + assert_eq!(key.name, "block-key"); + assert_eq!(key.algorithm, crate::crd::RndcAlgorithm::HmacSha256); + assert_eq!(key.secret, "dGVzdHNlY3JldA=="); + } + + #[test] + fn test_parse_rndc_key_file_ignores_single_line_block_comment() { + let rndc_key_content = r#"/* key "inline-commented" */ +key "inline-key" { + algorithm hmac-sha256; + secret "dGVzdHNlY3JldA=="; +}; +"#; + let mut data = BTreeMap::new(); + data.insert("rndc.key".to_string(), rndc_key_content.as_bytes().to_vec()); + + let key = Bind9Manager::parse_rndc_secret_data(&data).unwrap(); + + assert_eq!(key.name, "inline-key"); + assert_eq!(key.secret, "dGVzdHNlY3JldA=="); + } + + #[test] + fn test_parse_rndc_key_file_only_comments_is_error() { + // A file with nothing but comments must fail to parse, not silently + // produce a key from comment text. + let rndc_key_content = r#"# key "ghost" { +# algorithm hmac-sha256; +# secret "ZmFrZXNlY3JldA=="; +# }; +"#; + let mut data = BTreeMap::new(); + data.insert("rndc.key".to_string(), rndc_key_content.as_bytes().to_vec()); + + let result = Bind9Manager::parse_rndc_secret_data(&data); + + assert!(result.is_err(), "comment-only rndc.key must not parse"); + } } diff --git a/src/bind9/zone_ops.rs b/src/bind9/zone_ops.rs index d8bab02d..cda41ee6 100644 --- a/src/bind9/zone_ops.rs +++ b/src/bind9/zone_ops.rs @@ -36,6 +36,47 @@ impl std::fmt::Display for HttpError { impl std::error::Error for HttpError {} +/// Extract the HTTP status code from an error, if it originated from a +/// bindcar [`HttpError`]. +/// +/// Works even when the error has been wrapped in additional `anyhow` context +/// (e.g. by `zone_status`), because `anyhow::Error::downcast_ref` sees +/// through context layers. Never string-match on `e.to_string()` for status +/// codes: it only prints the outermost context. +fn bindcar_http_status(err: &anyhow::Error) -> Option { + err.downcast_ref::() + .map(|http_err| http_err.status) +} + +/// Returns `true` if the error is a bindcar HTTP 404 Not Found response. +pub(crate) fn is_http_not_found(err: &anyhow::Error) -> bool { + bindcar_http_status(err) == Some(StatusCode::NOT_FOUND) +} + +/// Returns `true` if the error is a bindcar HTTP 409 Conflict response. +pub(crate) fn is_http_conflict(err: &anyhow::Error) -> bool { + bindcar_http_status(err) == Some(StatusCode::CONFLICT) +} + +/// Returns `true` if a bindcar/BIND9 message indicates the zone already exists. +/// +/// BIND9 can return various messages for duplicate zones: +/// - "already exists" (including the BIND9 `zone X/IN: already exists` form) +/// - "already serves the given zone" +/// - "duplicate zone" +fn is_zone_already_exists_message(message: &str) -> bool { + let msg = message.to_lowercase(); + msg.contains("already exists") + || msg.contains("already serves") + || msg.contains("duplicate zone") +} + +/// Returns `true` if the error indicates the zone already exists — either an +/// HTTP 409 Conflict or a BIND9 "already exists"-style message. +fn is_zone_already_exists_error(err: &anyhow::Error) -> bool { + is_http_conflict(err) || is_zone_already_exists_message(&err.to_string()) +} + /// Build the API base URL from a server address /// /// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080" @@ -437,22 +478,20 @@ pub async fn zone_exists( debug!("Zone {zone_name} exists on {server}"); Ok(true) } + // 404 Not Found - zone definitely doesn't exist. Inspect the typed + // error in the chain: zone_status wraps the HttpError with anyhow + // context, so string-matching on e.to_string() would never see "404". + Err(e) if is_http_not_found(&e) => { + debug!("Zone {zone_name} does not exist on {server}"); + Ok(false) + } + // Rate limiting - should retry + Err(e) if bindcar_http_status(&e) == Some(StatusCode::TOO_MANY_REQUESTS) => { + error!("Rate limited while checking if zone {zone_name} exists on {server}: {e}"); + Err(e).context("Rate limited while checking zone existence") + } + // Any other error is a transient failure that should be retried Err(e) => { - let err_msg = e.to_string(); - - // 404 Not Found - zone definitely doesn't exist - if err_msg.contains("404") || err_msg.contains("not found") { - debug!("Zone {zone_name} does not exist on {server}"); - return Ok(false); - } - - // Rate limiting - should retry - if err_msg.contains("429") || err_msg.contains("Too Many Requests") { - error!("Rate limited while checking if zone {zone_name} exists on {server}: {e}"); - return Err(e).context("Rate limited while checking zone existence"); - } - - // Any other error is a transient failure that should be retried error!("Error checking if zone {zone_name} exists on {server}: {e}"); Err(e).context("Failed to check zone existence") } @@ -596,24 +635,9 @@ pub async fn add_primary_zone( Ok(true) } Err(e) => { - // Handle "zone already exists" errors as success (idempotent) - // Check if this is an HttpError with 409 Conflict status code - let is_conflict = e - .downcast_ref::() - .is_some_and(|http_err| http_err.status == StatusCode::CONFLICT); - - let err_msg = e.to_string().to_lowercase(); - // BIND9 can return various messages for duplicate zones: - // - "already exists" - // - "already serves the given zone" - // - "duplicate zone" - // - "zone X/IN: already exists" (BIND9 format) - // HTTP 409 Conflict means the zone already exists - if is_conflict - || err_msg.contains("already exists") - || err_msg.contains("already serves") - || err_msg.contains("duplicate zone") - { + // Handle "zone already exists" errors (HTTP 409 Conflict or a + // BIND9 duplicate-zone message) as success (idempotent) + if is_zone_already_exists_error(&e) { info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success"); // Zone exists - check if we need to update its configuration with secondary IPs @@ -702,16 +726,12 @@ pub async fn update_primary_zone( ); Ok(true) } - Err(e) => { - let error_msg = e.to_string(); - // If the zone doesn't exist, we can't update it - if error_msg.contains("not found") || error_msg.contains("404") { - debug!("Zone {zone_name} not found on {server}, cannot update"); - Ok(false) - } else { - Err(e).context("Failed to update zone configuration") - } + // If the zone doesn't exist, we can't update it + Err(e) if is_http_not_found(&e) => { + debug!("Zone {zone_name} not found on {server}, cannot update"); + Ok(false) } + Err(e) => Err(e).context("Failed to update zone configuration"), } } @@ -796,31 +816,13 @@ pub async fn add_secondary_zone( ); Ok(true) } - Err(e) => { - // Handle "zone already exists" errors as success (idempotent) - // Check if this is an HttpError with 409 Conflict status code - let is_conflict = e - .downcast_ref::() - .is_some_and(|http_err| http_err.status == StatusCode::CONFLICT); - - let err_msg = e.to_string().to_lowercase(); - // BIND9 can return various messages for duplicate zones: - // - "already exists" - // - "already serves the given zone" - // - "duplicate zone" - // - "zone X/IN: already exists" (BIND9 format) - // HTTP 409 Conflict means the zone already exists - if is_conflict - || err_msg.contains("already exists") - || err_msg.contains("already serves") - || err_msg.contains("duplicate zone") - { - info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success"); - Ok(false) - } else { - Err(e).context("Failed to add secondary zone") - } + // Handle "zone already exists" errors (HTTP 409 Conflict or a BIND9 + // duplicate-zone message) as success (idempotent) + Err(e) if is_zone_already_exists_error(&e) => { + info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success"); + Ok(false) } + Err(e) => Err(e).context("Failed to add secondary zone"), } } @@ -909,9 +911,14 @@ pub async fn add_zones( /// Create a zone via HTTP API with structured configuration. /// -/// This method sends a POST request to the API sidecar to create a zone using +/// This method sends a POST request to the API sidecar (via the shared +/// [`bindcar_request`] retry path, so it gets the same retry/backoff and +/// timeout behavior as all other zone operations) to create a zone using /// structured zone configuration from the bindcar library. /// +/// This operation is idempotent: an HTTP 409 Conflict (or a BIND9 +/// "already exists"-style message) is treated as success. +/// /// # Arguments /// * `client` - HTTP client /// * `token` - Authentication token @@ -925,7 +932,6 @@ pub async fn add_zones( /// /// Returns an error if the HTTP request fails or the zone cannot be created. #[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_lines)] pub async fn create_zone_http( client: &Arc, token: Option<&str>, @@ -935,22 +941,6 @@ pub async fn create_zone_http( server: &str, key_data: &RndcKeyData, ) -> Result<()> { - // Check if zone already exists (idempotent) - match zone_exists(client, token, zone_name, server).await { - Ok(true) => { - info!("Zone {zone_name} already exists on {server}, skipping creation"); - return Ok(()); - } - Ok(false) => { - // Zone doesn't exist, proceed with creation - } - Err(e) => { - // Can't check zone existence (rate limiting, network error, etc.) - // Propagate the error rather than blindly attempting creation - return Err(e).context("Failed to check if zone exists before creation"); - } - } - let base_url = build_api_url(server); let url = format!("{base_url}/api/v1/zones"); @@ -968,81 +958,36 @@ pub async fn create_zone_http( "Creating zone via HTTP API" ); - let mut post_request = client - .post(&url) - .header("Content-Type", "application/json") - .json(&request); - - // Add Authorization header only if token is provided - if let Some(token_value) = token { - post_request = post_request.header("Authorization", format!("Bearer {token_value}")); - } - - let response = post_request - .send() - .await - .context(format!("Failed to send HTTP request to {url}"))?; - - let status = response.status(); - - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - // Check if it's a "zone already exists" error (idempotent) - let error_lower = error_text.to_lowercase(); - let zone_check_result = zone_exists(client, token, zone_name, server).await; - if error_lower.contains("already exists") - || error_lower.contains("already serves") - || error_lower.contains("duplicate zone") - || status.as_u16() == 409 - || matches!(zone_check_result, Ok(true)) - { - info!("Zone {zone_name} already exists on {server} (detected via API error or existence check), treating as success"); + let body = match bindcar_request(client, token, "POST", &url, Some(&request)).await { + Ok(body) => body, + // HTTP 409 Conflict (or an "already exists" message) means the zone + // is already present - treat as success (idempotent) + Err(e) if is_zone_already_exists_error(&e) => { + info!("Zone {zone_name} already exists on {server}, treating as success"); return Ok(()); } - - // If we can't check zone existence due to rate limiting or other errors, propagate that - if let Err(zone_err) = zone_check_result { - return Err(zone_err).context("Failed to verify zone existence after creation error"); + Err(e) => { + error!( + zone_name = %zone_name, + server = %server, + error = %e, + "Failed to create zone via HTTP API" + ); + return Err(e) + .with_context(|| format!("Failed to create zone '{zone_name}' via HTTP API")); } + }; - error!( - zone_name = %zone_name, - server = %server, - status = %status, - error = %error_text, - "Failed to create zone via HTTP API" - ); - anyhow::bail!("Failed to create zone '{zone_name}' via HTTP API: {status} - {error_text}"); - } - - let result: ZoneResponse = response - .json() - .await - .context("Failed to parse API response")?; + let result: ZoneResponse = + serde_json::from_str(&body).context("Failed to parse API response")?; if !result.success { // Check if the error message indicates zone already exists (idempotent) - let msg_lower = result.message.to_lowercase(); - let zone_check_result = zone_exists(client, token, zone_name, server).await; - if msg_lower.contains("already exists") - || msg_lower.contains("already serves") - || msg_lower.contains("duplicate zone") - || matches!(zone_check_result, Ok(true)) - { + if is_zone_already_exists_message(&result.message) { info!("Zone {zone_name} already exists on {server} (detected via API response), treating as success"); return Ok(()); } - // If we can't check zone existence due to rate limiting or other errors, propagate that - if let Err(zone_err) = zone_check_result { - return Err(zone_err) - .context("Failed to verify zone existence after API returned error"); - } - error!( zone_name = %zone_name, server = %server, @@ -1102,16 +1047,12 @@ pub async fn delete_zone( info!("Deleted zone {zone_name} from {server}"); Ok(()) } - Err(e) => { - let error_msg = e.to_string(); - // If the zone doesn't exist, consider it already deleted (idempotent) - if error_msg.contains("not found") || error_msg.contains("404") { - debug!("Zone {zone_name} already deleted from {server}"); - Ok(()) - } else { - Err(e).context("Failed to delete zone") - } + // If the zone doesn't exist, consider it already deleted (idempotent) + Err(e) if is_http_not_found(&e) => { + debug!("Zone {zone_name} already deleted from {server}"); + Ok(()) } + Err(e) => Err(e).context("Failed to delete zone"), } } diff --git a/src/bind9/zone_ops_tests.rs b/src/bind9/zone_ops_tests.rs index 34389e6c..69165974 100644 --- a/src/bind9/zone_ops_tests.rs +++ b/src/bind9/zone_ops_tests.rs @@ -8,6 +8,16 @@ mod tests { use crate::bind9::{Bind9Manager, RndcKeyData}; use bindcar::ZONE_TYPE_PRIMARY; + /// Install the ring TLS crypto provider for this test process. + /// + /// reqwest is compiled with `rustls-no-provider` and relies on the + /// process-default `CryptoProvider`, which `main.rs` installs at startup + /// but unit tests do not. `install_default` returns `Err` once a provider + /// is already set, so calling it from every test is safe and idempotent. + fn ensure_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + // ===================================================== // HTTP API URL Building Tests // ===================================================== @@ -218,28 +228,246 @@ mod tests { #[tokio::test] #[ignore = "Requires mock HTTP server"] - async fn test_zone_exists_false() { + async fn test_zone_exists_connection_error() { let manager = Bind9Manager::new(); + // Should return Err on connection error, not Ok(false) let result = manager - .zone_exists("definitely-does-not-exist-12345.com", "localhost:8080") + .zone_exists("example.com", "invalid-host:99999") .await; - // Should return Ok(false) for 404 not found - assert!(matches!(result, Ok(false))); + assert!(result.is_err()); + } + + // ===================================================== + // HTTP error inspection helpers (zone_exists 404 fix) + // ===================================================== + + use super::super::{is_http_conflict, is_http_not_found, HttpError}; + use reqwest::StatusCode; + + fn http_error(status: StatusCode, message: &str) -> anyhow::Error { + anyhow::Error::from(HttpError { + status, + message: message.to_string(), + }) + } + + #[test] + fn test_is_http_not_found_on_bare_error() { + let err = http_error(StatusCode::NOT_FOUND, "zone not found"); + assert!(is_http_not_found(&err)); + } + + #[test] + fn test_is_http_not_found_through_context_layers() { + // zone_status() wraps the HttpError with anyhow context; the helper + // must see through the context layers (string-matching on + // e.to_string() only prints the outermost context and made the 404 + // branch unreachable). + let err = http_error(StatusCode::NOT_FOUND, "zone not found") + .context("Failed to get zone status") + .context("outer context"); + assert!(is_http_not_found(&err)); + } + + #[test] + fn test_is_http_not_found_rejects_other_statuses() { + let err = http_error(StatusCode::INTERNAL_SERVER_ERROR, "boom") + .context("Failed to get zone status"); + assert!(!is_http_not_found(&err)); + } + + #[test] + fn test_is_http_not_found_rejects_non_http_errors() { + let err = anyhow::anyhow!("connection reset by peer"); + assert!(!is_http_not_found(&err)); + } + + #[test] + fn test_is_http_conflict_on_bare_error() { + let err = http_error(StatusCode::CONFLICT, "zone already exists"); + assert!(is_http_conflict(&err)); } + #[test] + fn test_is_http_conflict_through_context_layers() { + let err = + http_error(StatusCode::CONFLICT, "zone already exists").context("Failed to add zone"); + assert!(is_http_conflict(&err)); + } + + #[test] + fn test_is_http_conflict_rejects_not_found() { + let err = http_error(StatusCode::NOT_FOUND, "zone not found"); + assert!(!is_http_conflict(&err)); + } + + // ===================================================== + // zone_exists against a mock bindcar server + // ===================================================== + + use std::sync::Arc; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + #[tokio::test] - #[ignore = "Requires mock HTTP server"] - async fn test_zone_exists_connection_error() { - let manager = Bind9Manager::new(); + async fn test_zone_exists_returns_false_on_404() { + ensure_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/zones/missing.example.com/status")) + .respond_with(ResponseTemplate::new(404).set_body_string("zone not found")) + .mount(&server) + .await; - // Should return Err on connection error, not Ok(false) - let result = manager - .zone_exists("example.com", "invalid-host:99999") + let client = Arc::new(reqwest::Client::new()); + let result = super::super::zone_exists(&client, None, "missing.example.com", &server.uri()) + .await + .expect("404 must map to Ok(false), not Err"); + + assert!(!result, "a 404 from bindcar means the zone does not exist"); + } + + #[tokio::test] + async fn test_zone_exists_returns_true_on_200() { + ensure_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/zones/present.example.com/status")) + .respond_with(ResponseTemplate::new(200).set_body_string("zone is loaded")) + .mount(&server) .await; - assert!(result.is_err()); + let client = Arc::new(reqwest::Client::new()); + let result = super::super::zone_exists(&client, None, "present.example.com", &server.uri()) + .await + .expect("200 must map to Ok(true)"); + + assert!(result); + } + + // ===================================================== + // create_zone_http via the shared retry path + // ===================================================== + + fn test_key_data() -> RndcKeyData { + RndcKeyData { + name: "test-key".to_string(), + algorithm: crate::crd::RndcAlgorithm::HmacSha256, + secret: "dGVzdHNlY3JldA==".to_string(), + } + } + + fn test_zone_config() -> bindcar::ZoneConfig { + use bindcar::{SoaRecord, ZoneConfig}; + use std::collections::HashMap; + + ZoneConfig { + ttl: 3600, + soa: SoaRecord { + primary_ns: "ns1.example.com.".to_string(), + admin_email: "admin.example.com.".to_string(), + serial: 1, + refresh: 3600, + retry: 600, + expire: 604_800, + negative_ttl: 86400, + }, + name_servers: vec!["ns1.example.com.".to_string()], + name_server_ips: HashMap::new(), + records: vec![], + also_notify: None, + allow_transfer: None, + primaries: None, + dnssec_policy: None, + inline_signing: None, + } + } + + #[tokio::test] + async fn test_create_zone_http_success() { + ensure_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/zones")) + .respond_with( + ResponseTemplate::new(201).set_body_string( + r#"{"success": true, "message": "Zone created successfully"}"#, + ), + ) + .mount(&server) + .await; + + let client = Arc::new(reqwest::Client::new()); + let result = super::super::create_zone_http( + &client, + None, + "new.example.com", + ZONE_TYPE_PRIMARY, + test_zone_config(), + &server.uri(), + &test_key_data(), + ) + .await; + + assert!( + result.is_ok(), + "successful creation must return Ok: {result:?}" + ); + } + + #[tokio::test] + async fn test_create_zone_http_treats_409_as_success() { + ensure_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/zones")) + .respond_with(ResponseTemplate::new(409).set_body_string("zone already exists")) + .mount(&server) + .await; + + let client = Arc::new(reqwest::Client::new()); + let result = super::super::create_zone_http( + &client, + None, + "dup.example.com", + ZONE_TYPE_PRIMARY, + test_zone_config(), + &server.uri(), + &test_key_data(), + ) + .await; + + assert!( + result.is_ok(), + "409 Conflict means the zone already exists and must be idempotent: {result:?}" + ); + } + + #[tokio::test] + async fn test_create_zone_http_fails_on_bad_request() { + ensure_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/zones")) + .respond_with(ResponseTemplate::new(400).set_body_string("invalid zone name")) + .mount(&server) + .await; + + let client = Arc::new(reqwest::Client::new()); + let result = super::super::create_zone_http( + &client, + None, + "bad zone", + ZONE_TYPE_PRIMARY, + test_zone_config(), + &server.uri(), + &test_key_data(), + ) + .await; + + assert!(result.is_err(), "a 400 must not be swallowed"); } // ===================================================== diff --git a/src/bind9_resources.rs b/src/bind9_resources.rs index ceed5d0c..df980f13 100644 --- a/src/bind9_resources.rs +++ b/src/bind9_resources.rs @@ -51,10 +51,7 @@ dnssec-policy "{{POLICY_NAME}}" { ksk lifetime {{KSK_LIFETIME}} algorithm {{ALGORITHM}}; zsk lifetime {{ZSK_LIFETIME}} algorithm {{ALGORITHM}}; }; - - // Authenticated denial of existence - {{NSEC_CONFIG}}; - +{{NSEC_CONFIG}} // Signature validity periods signatures-refresh 5d; signatures-validity 30d; @@ -102,6 +99,12 @@ const VOLUME_DNSSEC_KEYS: &str = "dnssec-keys"; /// for `nsupdate -k`. const VOLUME_TMP: &str = "tmp"; +// named.conf.options directive names for listen addresses +const LISTEN_ON_DIRECTIVE: &str = "listen-on"; +const LISTEN_ON_V6_DIRECTIVE: &str = "listen-on-v6"; +/// Default listen address match list when `listenOn` / `listenOnV6` are unset. +const LISTEN_ON_DEFAULT: &str = "any"; + // Default DNSSEC signing parameters const DEFAULT_DNSSEC_POLICY_NAME: &str = "default"; const DEFAULT_DNSSEC_ALGORITHM: &str = "ECDSAP256SHA256"; @@ -126,22 +129,14 @@ pub(crate) fn generate_dnssec_policies( global_config: Option<&crate::crd::Bind9Config>, instance_config: Option<&crate::crd::Bind9Config>, ) -> String { - // Check instance config first, then fall back to global config - let dnssec_config = if let Some(instance) = instance_config { - instance.dnssec.as_ref().and_then(|d| d.signing.as_ref()) - } else { - global_config.and_then(|g| g.dnssec.as_ref().and_then(|d| d.signing.as_ref())) - }; - - // If no DNSSEC signing config or not enabled, return empty string - let Some(signing) = dnssec_config else { + // Resolve the signing config with the same precedence/fallback semantics + // as get_dnssec_signing_config: instance config wins when it enables + // signing, otherwise fall back to the global config. Returns None when + // signing is not enabled anywhere. + let Some(signing) = get_dnssec_signing_config(global_config, instance_config) else { return String::new(); }; - if !signing.enabled { - return String::new(); - } - // Extract policy parameters with defaults let policy_name = signing .policy @@ -160,13 +155,17 @@ pub(crate) fn generate_dnssec_policies( .as_deref() .unwrap_or(DEFAULT_ZSK_LIFETIME); - // Configure NSEC/NSEC3 + // Configure NSEC/NSEC3. BIND 9.18 dnssec-policy grammar has an + // `nsec3param` statement but NO `nsec` keyword: NSEC is selected by + // omitting `nsec3param`, so the NSEC case renders nothing at all. let nsec_config = if signing.nsec3.unwrap_or(false) { let iterations = signing.nsec3_iterations.unwrap_or(0); let salt_length = DEFAULT_NSEC3_SALT_LENGTH; - format!("nsec3param iterations {iterations} optout no salt-length {salt_length}") + format!( + "\n // Authenticated denial of existence (NSEC3)\n nsec3param iterations {iterations} optout no salt-length {salt_length};\n" + ) } else { - "nsec".to_string() + String::new() }; // Substitute template variables @@ -467,12 +466,16 @@ pub fn build_owner_references(instance: &Bind9Instance) -> Vec { /// Builds a Kubernetes `ConfigMap` containing BIND9 configuration files. /// -/// Creates a `ConfigMap` with: -/// - `named.conf` - Main BIND9 configuration -/// - `named.conf.options` - BIND9 options (recursion, ACLs, DNSSEC, etc.) +/// Creates a `ConfigMap` with the files NOT overridden by custom +/// `configMapRefs` (instance overrides cluster): +/// - `named.conf` - Main BIND9 configuration (omitted when `namedConf` ref is set) +/// - `named.conf.options` - BIND9 options (omitted when `namedConfOptions` ref is set) +/// - `rndc.conf` - RNDC client configuration (ALWAYS generated; it is not +/// overridable and is mounted from this `ConfigMap` unconditionally) /// -/// If custom `ConfigMaps` are referenced in the cluster or instance spec, this function -/// will not generate configuration files, as they should be provided by the user. +/// Because `rndc.conf` is always present, this `ConfigMap` must always be +/// created — even when both `namedConf` and `namedConfOptions` refs are set — +/// so the pod's `config` volume always has a backing `ConfigMap`. /// /// # Arguments /// @@ -480,20 +483,23 @@ pub fn build_owner_references(instance: &Bind9Instance) -> Vec { /// * `namespace` - Kubernetes namespace /// * `instance` - `Bind9Instance` spec containing configuration options /// * `cluster` - Optional `Bind9Cluster` containing shared configuration +/// * `role_allow_transfer` - Role-specific allow-transfer override from cluster spec /// /// # Returns /// -/// A Kubernetes `ConfigMap` resource ready for creation/update, or None if custom `ConfigMaps` are used +/// A Kubernetes `ConfigMap` resource ready for creation/update +/// /// # Errors -/// Returns an error if any ACL entry in the instance or cluster spec fails -/// validation — see [`crate::bind9_acl`] for the accepted syntax. +/// Returns an error if any ACL, forwarder, or listen-address entry in the +/// instance or cluster spec fails validation — see [`crate::bind9_acl`] for +/// the accepted ACL syntax. pub fn build_configmap( name: &str, namespace: &str, instance: &Bind9Instance, cluster: Option<&Bind9Cluster>, role_allow_transfer: Option<&Vec>, -) -> anyhow::Result> { +) -> anyhow::Result { debug!( name = %name, namespace = %namespace, @@ -507,32 +513,28 @@ pub fn build_configmap( .as_ref() .or_else(|| cluster.and_then(|c| c.spec.common.config_map_refs.as_ref())); - // If custom ConfigMaps are specified, don't generate a ConfigMap - if let Some(refs) = config_map_refs { - if refs.named_conf.is_some() || refs.named_conf_options.is_some() { - debug!( - named_conf_ref = ?refs.named_conf, - named_conf_options_ref = ?refs.named_conf_options, - "Custom ConfigMaps specified, skipping generation" - ); - // User is providing custom ConfigMaps, so we don't create one - return Ok(None); - } - } + let named_conf_overridden = config_map_refs.is_some_and(|refs| refs.named_conf.is_some()); + let options_overridden = config_map_refs.is_some_and(|refs| refs.named_conf_options.is_some()); - // Generate default configuration + // Generate configuration files not overridden by custom ConfigMap refs let mut data = BTreeMap::new(); let labels = build_labels_from_instance(name, instance); - // Build named.conf - let named_conf = build_named_conf(instance, cluster); - data.insert(NAMED_CONF_FILENAME.into(), named_conf); + // Build named.conf (unless the user supplies it via namedConf ref) + if !named_conf_overridden { + let named_conf = build_named_conf(instance, cluster); + data.insert(NAMED_CONF_FILENAME.into(), named_conf); + } - // Build named.conf.options (validates ACL entries before templating) - let options_conf = build_options_conf(instance, cluster, role_allow_transfer)?; - data.insert(NAMED_CONF_OPTIONS_FILENAME.into(), options_conf); + // Build named.conf.options (unless the user supplies it via namedConfOptions ref); + // validates ACL entries before templating + if !options_overridden { + let options_conf = build_options_conf(instance, cluster, role_allow_transfer)?; + data.insert(NAMED_CONF_OPTIONS_FILENAME.into(), options_conf); + } - // Build rndc.conf (references key file mounted from Secret) + // Build rndc.conf (references key file mounted from Secret). This file is + // never overridable, so the generated ConfigMap always exists. data.insert(RNDC_CONF_FILENAME.into(), RNDC_CONF_TEMPLATE.to_string()); // Note: We do NOT auto-generate named.conf.zones anymore. @@ -540,7 +542,7 @@ pub fn build_configmap( let owner_refs = build_owner_references(instance); - Ok(Some(ConfigMap { + Ok(ConfigMap { metadata: ObjectMeta { name: Some(format!("{name}-config")), namespace: Some(namespace.into()), @@ -550,7 +552,7 @@ pub fn build_configmap( }, data: Some(data), ..Default::default() - })) + }) } /// Builds a cluster-level shared `ConfigMap` containing BIND9 configuration files. @@ -659,7 +661,8 @@ fn build_named_conf(instance: &Bind9Instance, cluster: Option<&Bind9Cluster>) -> /// Build the named.conf.options configuration from template /// /// Generates the BIND9 options configuration file from the instance's config spec. -/// Includes settings for recursion, ACLs (allow-query, allow-transfer), and DNSSEC. +/// Includes settings for recursion, ACLs (allow-query, allow-transfer), DNSSEC, +/// forwarders, and listen addresses (listen-on / listen-on-v6). /// /// Priority for configuration values (highest to lowest): /// 1. Instance-level settings (`instance.spec.config`) @@ -839,15 +842,99 @@ fn build_options_conf( // Generate DNSSEC policies (instance config overrides global) let dnssec_policies = generate_dnssec_policies(global_config, instance.spec.config.as_ref()); + // Forwarders and listen addresses - instance overrides global, per field + let instance_cfg = instance.spec.config.as_ref(); + let forwarders = render_forwarders( + instance_cfg + .and_then(|c| c.forwarders.as_ref()) + .or_else(|| global_config.and_then(|g| g.forwarders.as_ref())), + )?; + let listen_on = render_listen_on( + LISTEN_ON_DIRECTIVE, + instance_cfg + .and_then(|c| c.listen_on.as_ref()) + .or_else(|| global_config.and_then(|g| g.listen_on.as_ref())), + )?; + let listen_on_v6 = render_listen_on( + LISTEN_ON_V6_DIRECTIVE, + instance_cfg + .and_then(|c| c.listen_on_v6.as_ref()) + .or_else(|| global_config.and_then(|g| g.listen_on_v6.as_ref())), + )?; + // Perform template substitutions Ok(NAMED_CONF_OPTIONS_TEMPLATE + .replace("{{LISTEN_ON}}", &listen_on) + .replace("{{LISTEN_ON_V6}}", &listen_on_v6) .replace("{{RECURSION}}", &recursion) + .replace("{{FORWARDERS}}", &forwarders) .replace("{{ALLOW_QUERY}}", &allow_query) .replace("{{ALLOW_TRANSFER}}", &allow_transfer) .replace("{{DNSSEC_VALIDATE}}", &dnssec_validate) .replace("{{DNSSEC_POLICIES}}", &dnssec_policies)) } +/// Render the `forwarders { …; };` block for named.conf.options. +/// +/// Emits only the `forwarders` block (no `forward` mode statement), matching +/// BIND defaults. Returns an empty string when `forwarders` is `None` or +/// empty so no directive is rendered. +/// +/// # Arguments +/// +/// * `forwarders` - Optional list of upstream DNS server IP addresses +/// +/// # Errors +/// +/// Returns an error if any entry is not a plain IPv4 or IPv6 address — +/// CRD-supplied values flow directly into named.conf, so anything else is +/// rejected to prevent configuration injection. +fn render_forwarders(forwarders: Option<&Vec>) -> anyhow::Result { + let Some(list) = forwarders else { + return Ok(String::new()); + }; + if list.is_empty() { + return Ok(String::new()); + } + + for entry in list { + let trimmed = entry.trim(); + if trimmed.parse::().is_err() { + anyhow::bail!("invalid forwarder {trimmed:?}: must be a plain IPv4 or IPv6 address"); + } + } + + let joined = list + .iter() + .map(|entry| entry.trim().to_string()) + .collect::>() + .join("; "); + Ok(format!("forwarders {{ {joined}; }};")) +} + +/// Render a `listen-on` / `listen-on-v6` directive for named.conf.options. +/// +/// Defaults to `{directive} port 53 {{ any; }};` when `addresses` is `None` +/// or empty, preserving the previous hardcoded behavior. +/// +/// # Arguments +/// +/// * `directive` - Either [`LISTEN_ON_DIRECTIVE`] or [`LISTEN_ON_V6_DIRECTIVE`] +/// * `addresses` - Optional address match list from the CRD +/// +/// # Errors +/// +/// Returns an error if any entry fails address-match-list validation — see +/// [`crate::bind9_acl`] for the accepted syntax. +fn render_listen_on(directive: &str, addresses: Option<&Vec>) -> anyhow::Result { + let list = match addresses { + Some(addrs) if !addrs.is_empty() => build_acl_list(addrs) + .with_context(|| format!("invalid entry in {directive} address list"))?, + _ => LISTEN_ON_DEFAULT.to_string(), + }; + Ok(format!("{directive} port {DNS_PORT} {{ {list}; }};")) +} + /// Build the main named.conf configuration for a cluster from template /// /// Generates the main BIND9 configuration file with conditional zones include. @@ -890,7 +977,8 @@ fn build_cluster_named_conf(cluster: &Bind9Cluster) -> String { /// Build the named.conf.options configuration for a cluster from template /// /// Generates the BIND9 options configuration file from the cluster's `spec.global` config. -/// Includes settings for recursion, ACLs (allow-query, allow-transfer), and DNSSEC. +/// Includes settings for recursion, ACLs (allow-query, allow-transfer), DNSSEC, +/// forwarders, and listen addresses (listen-on / listen-on-v6). /// /// # Arguments /// @@ -950,8 +1038,23 @@ fn build_cluster_options_conf(cluster: &Bind9Cluster) -> anyhow::Result // Generate DNSSEC policies from global config let dnssec_policies = generate_dnssec_policies(cluster.spec.common.global.as_ref(), None); + // Forwarders and listen addresses from global config + let global = cluster.spec.common.global.as_ref(); + let forwarders = render_forwarders(global.and_then(|g| g.forwarders.as_ref()))?; + let listen_on = render_listen_on( + LISTEN_ON_DIRECTIVE, + global.and_then(|g| g.listen_on.as_ref()), + )?; + let listen_on_v6 = render_listen_on( + LISTEN_ON_V6_DIRECTIVE, + global.and_then(|g| g.listen_on_v6.as_ref()), + )?; + Ok(NAMED_CONF_OPTIONS_TEMPLATE + .replace("{{LISTEN_ON}}", &listen_on) + .replace("{{LISTEN_ON_V6}}", &listen_on_v6) .replace("{{RECURSION}}", &recursion) + .replace("{{FORWARDERS}}", &forwarders) .replace("{{ALLOW_QUERY}}", &allow_query) .replace("{{ALLOW_TRANSFER}}", &allow_transfer) .replace("{{DNSSEC_VALIDATE}}", &dnssec_validate) @@ -973,6 +1076,9 @@ fn build_cluster_options_conf(cluster: &Bind9Cluster) -> anyhow::Result /// * `namespace` - Kubernetes namespace /// * `instance` - `Bind9Instance` spec containing replicas, version, etc. /// * `cluster` - Optional `Bind9Cluster` containing shared configuration +/// * `cluster_provider` - Optional `ClusterBind9Provider` containing shared configuration +/// * `rndc_secret_name` - Resolved RNDC `Secret` name (from `rndcKey.secretRef`, +/// an inline secret spec, or the auto-generated `{name}-rndc-key` default) /// /// # Returns /// @@ -987,7 +1093,6 @@ struct DeploymentConfig<'a> { volume_mounts: Option<&'a Vec>, bindcar_config: Option<&'a crate::crd::BindcarConfig>, configmap_name: String, - rndc_secret_name: String, } /// Extract and resolve deployment configuration from instance and cluster @@ -1071,10 +1176,6 @@ fn resolve_deployment_config<'a>( format!("{}-config", instance.spec.cluster_ref) }; - // Determine RNDC secret name - // TODO: Use actual RNDC config precedence resolution when implemented - let rndc_secret_name = format!("{name}-rndc-key"); - DeploymentConfig { image_config, config_map_refs, @@ -1083,7 +1184,6 @@ fn resolve_deployment_config<'a>( volume_mounts, bindcar_config, configmap_name, - rndc_secret_name, } } @@ -1093,6 +1193,7 @@ pub fn build_deployment( instance: &Bind9Instance, cluster: Option<&Bind9Cluster>, cluster_provider: Option<&crate::crd::ClusterBind9Provider>, + rndc_secret_name: &str, ) -> Deployment { debug!( name = %name, @@ -1162,7 +1263,7 @@ pub fn build_deployment( }), spec: Some(build_pod_spec( &config.configmap_name, - &config.rndc_secret_name, + rndc_secret_name, config.version, config.image_config, config.config_map_refs, @@ -1658,6 +1759,10 @@ fn build_volume_mounts( /// for each custom `ConfigMap`. If `namedConfZones` is not specified, no zones `ConfigMap` volume /// is created. /// +/// The generated `config` volume is ALWAYS present regardless of custom refs: +/// it backs the unconditional `rndc.conf` mounts in both containers and the +/// generated `ConfigMap` always exists (it always contains at least `rndc.conf`). +/// /// # Arguments /// /// * `configmap_name` - Name of the `ConfigMap` to mount (instance or cluster `ConfigMap`) @@ -1739,30 +1844,21 @@ fn build_volumes( ..Default::default() }); } + } - // If any of the named.conf or named.conf.options use defaults, add the config volume - // This ensures volume mounts have a corresponding volume - if refs.named_conf.is_none() || refs.named_conf_options.is_none() { - volumes.push(Volume { - name: VOLUME_CONFIG.into(), - config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource { - name: configmap_name.to_string(), - ..Default::default() - }), - ..Default::default() - }); - } - } else { - // No custom ConfigMaps, use default generated one (cluster or instance ConfigMap) - volumes.push(Volume { - name: VOLUME_CONFIG.into(), - config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource { - name: configmap_name.to_string(), - ..Default::default() - }), + // ALWAYS add the generated config volume. The generated ConfigMap always + // exists (it always carries at least rndc.conf, which is not overridable), + // and rndc.conf is mounted from this volume unconditionally in both the + // bind9 and bindcar containers. Omitting it when custom refs are set would + // leave those mounts dangling and make the API server reject the Deployment. + volumes.push(Volume { + name: VOLUME_CONFIG.into(), + config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource { + name: configmap_name.to_string(), ..Default::default() - }); - } + }), + ..Default::default() + }); // Append custom volumes from cluster/instance if let Some(custom_vols) = custom_volumes { diff --git a/src/bind9_resources_tests.rs b/src/bind9_resources_tests.rs index c70484b3..ff56b802 100644 --- a/src/bind9_resources_tests.rs +++ b/src/bind9_resources_tests.rs @@ -126,7 +126,14 @@ mod tests { ); instance.metadata.labels = Some(instance_labels); - let deployment = build_deployment("test-instance", "test-ns", &instance, None, None); + let deployment = build_deployment( + "test-instance", + "test-ns", + &instance, + None, + None, + "test-instance-rndc-key", + ); let labels = deployment.metadata.labels.as_ref().unwrap(); assert_eq!( @@ -146,7 +153,14 @@ mod tests { instance_labels.insert(BINDY_ROLE_LABEL.to_string(), ROLE_PRIMARY.to_string()); instance.metadata.labels = Some(instance_labels); - let deployment = build_deployment("test-primary", "test-ns", &instance, None, None); + let deployment = build_deployment( + "test-primary", + "test-ns", + &instance, + None, + None, + "test-primary-rndc-key", + ); // Check deployment metadata labels let deployment_labels = deployment.metadata.labels.as_ref().unwrap(); @@ -198,7 +212,14 @@ mod tests { let instance = create_test_instance("test-instance"); // Don't add role label - let deployment = build_deployment("test-instance", "test-ns", &instance, None, None); + let deployment = build_deployment( + "test-instance", + "test-ns", + &instance, + None, + None, + "test-instance-rndc-key", + ); let labels = deployment.metadata.labels.as_ref().unwrap(); assert!( @@ -231,9 +252,7 @@ mod tests { #[test] fn test_build_configmap() { let instance = create_test_instance("test"); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); assert_eq!(cm.metadata.name.as_deref(), Some("test-config")); assert_eq!(cm.metadata.namespace.as_deref(), Some("test-ns")); @@ -252,7 +271,8 @@ mod tests { #[test] fn test_build_deployment() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); assert_eq!(deployment.metadata.name.as_deref(), Some("test")); assert_eq!(deployment.metadata.namespace.as_deref(), Some("test-ns")); @@ -300,7 +320,8 @@ mod tests { #[test] fn test_build_pod_spec() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); // Verify volumes @@ -343,7 +364,8 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.replicas = Some(5); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let spec = deployment.spec.unwrap(); assert_eq!(spec.replicas, Some(5)); @@ -354,7 +376,8 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.version = Some("9.20".into()); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let container = &pod_spec.containers[0]; @@ -409,9 +432,7 @@ mod tests { #[test] fn test_configmap_contains_all_files() { let instance = create_test_instance("test"); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let data = cm.data.unwrap(); assert!(data.contains_key("named.conf")); @@ -437,9 +458,7 @@ mod tests { #[test] fn test_configmap_naming() { let instance = create_test_instance("test-instance"); - let cm = build_configmap("test-instance", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test-instance", "test-ns", &instance, None, None).unwrap(); assert_eq!(cm.metadata.name.as_deref(), Some("test-instance-config")); assert_eq!(cm.metadata.namespace.as_deref(), Some("test-ns")); @@ -448,7 +467,8 @@ mod tests { #[test] fn test_deployment_selector_matches_labels() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let spec = deployment.spec.unwrap(); let selector_labels = spec.selector.match_labels.unwrap(); @@ -479,54 +499,130 @@ mod tests { // Comprehensive Edge-Case Tests for Missing Coverage // ===================================================== - #[test] - fn test_configmap_with_custom_refs_returns_none() { + // ===================================================== + // bug-044: custom configMapRefs must never break the + // generated ConfigMap / `config` volume / mounts triad + // ===================================================== + + /// Build a standalone instance (no clusterRef) with the given + /// `configMapRefs`, so the generated `ConfigMap` name (`{name}-config`) + /// matches what the `config` volume must reference. + fn standalone_instance_with_refs( + name: &str, + named_conf: Option<&str>, + named_conf_options: Option<&str>, + ) -> Bind9Instance { use crate::crd::ConfigMapRefs; - let mut instance = create_test_instance("test"); + let mut instance = create_test_instance(name); + instance.spec.cluster_ref = String::new(); instance.spec.config_map_refs = Some(ConfigMapRefs { named_conf_zones: None, - named_conf: Some("custom-named-conf".to_string()), - named_conf_options: None, + named_conf: named_conf.map(ToString::to_string), + named_conf_options: named_conf_options.map(ToString::to_string), }); + instance + } + + /// Assert the ConfigMap / volume / mount contract for an instance: + /// - the generated `ConfigMap` always exists and always carries + /// `rndc.conf`, plus exactly the files NOT overridden by refs + /// - the `config` volume always exists and points at the generated + /// `ConfigMap` + /// - every volumeMount in every container has a matching volume + fn assert_configmap_volume_mount_consistency( + instance: &Bind9Instance, + name: &str, + expect_named_conf_generated: bool, + expect_options_generated: bool, + ) { + // Generated ConfigMap must always exist with the expected files. + let cm = build_configmap(name, "test-ns", instance, None, None) + .expect("build_configmap must succeed"); + let data = cm.data.expect("generated ConfigMap must have data"); + assert!( + data.contains_key("rndc.conf"), + "generated ConfigMap must always contain rndc.conf" + ); + assert_eq!( + data.contains_key("named.conf"), + expect_named_conf_generated, + "named.conf presence mismatch in generated ConfigMap" + ); + assert_eq!( + data.contains_key("named.conf.options"), + expect_options_generated, + "named.conf.options presence mismatch in generated ConfigMap" + ); - let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + let deployment = build_deployment( + name, + "test-ns", + instance, + None, + None, + &format!("{name}-rndc-key"), + ); + let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); + let volumes = pod_spec.volumes.as_ref().expect("pod must have volumes"); - // Should return None when custom ConfigMaps are referenced - assert!(cm.is_none()); + // The `config` volume must always exist and reference the generated + // ConfigMap (it always carries at least rndc.conf). + let config_volume = volumes + .iter() + .find(|v| v.name == "config") + .expect("`config` volume must always be present"); + assert_eq!( + config_volume + .config_map + .as_ref() + .expect("`config` volume must be ConfigMap-backed") + .name, + format!("{name}-config"), + "`config` volume must reference the generated ConfigMap" + ); + + // Every mount in every container must reference an existing volume, + // otherwise the API server rejects the Deployment (422). + for container in &pod_spec.containers { + for mount in container.volume_mounts.as_ref().unwrap() { + assert!( + volumes.iter().any(|v| v.name == mount.name), + "container {} mounts volume {:?} which does not exist in pod volumes", + container.name, + mount.name + ); + } + } } #[test] - fn test_configmap_with_custom_options_ref_returns_none() { - use crate::crd::ConfigMapRefs; - + fn test_configmap_volume_consistency_no_refs() { let mut instance = create_test_instance("test"); - instance.spec.config_map_refs = Some(ConfigMapRefs { - named_conf_zones: None, - named_conf: None, - named_conf_options: Some("custom-options".to_string()), - }); - - let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); - - // Should return None when custom ConfigMaps are referenced - assert!(cm.is_none()); + instance.spec.cluster_ref = String::new(); + assert_configmap_volume_mount_consistency(&instance, "test", true, true); } #[test] - fn test_configmap_with_both_custom_refs_returns_none() { - use crate::crd::ConfigMapRefs; - - let mut instance = create_test_instance("test"); - instance.spec.config_map_refs = Some(ConfigMapRefs { - named_conf_zones: None, - named_conf: Some("custom-named-conf".to_string()), - named_conf_options: Some("custom-options".to_string()), - }); + fn test_configmap_volume_consistency_only_named_conf_ref() { + let instance = standalone_instance_with_refs("test", Some("custom-named-conf"), None); + assert_configmap_volume_mount_consistency(&instance, "test", false, true); + } - let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + #[test] + fn test_configmap_volume_consistency_only_options_ref() { + let instance = standalone_instance_with_refs("test", None, Some("custom-options")); + assert_configmap_volume_mount_consistency(&instance, "test", true, false); + } - assert!(cm.is_none()); + #[test] + fn test_configmap_volume_consistency_both_refs() { + let instance = standalone_instance_with_refs( + "test", + Some("custom-named-conf"), + Some("custom-options"), + ); + assert_configmap_volume_mount_consistency(&instance, "test", false, false); } #[test] @@ -542,8 +638,11 @@ mod tests { let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); - // Should still generate ConfigMap if refs exist but are empty - assert!(cm.is_some()); + // Should still generate a full ConfigMap if refs exist but are empty + let data = cm.data.unwrap(); + assert!(data.contains_key("named.conf")); + assert!(data.contains_key("named.conf.options")); + assert!(data.contains_key("rndc.conf")); } #[test] @@ -573,9 +672,7 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.config.as_mut().unwrap().allow_query = Some(vec![]); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // Empty allow_query list should result in no allow-query directive @@ -587,9 +684,7 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.config.as_mut().unwrap().allow_transfer = Some(vec![]); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // Empty allow_transfer list should result in "none" @@ -601,9 +696,7 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.config = None; - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // Defaults should be applied - recursion no, but NO allow-transfer directive @@ -620,9 +713,7 @@ mod tests { signing: None, }); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // Should contain dnssec-validation no when disabled @@ -638,9 +729,7 @@ mod tests { signing: None, }); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // DNSSEC is always enabled in BIND 9.15+, only validation can be configured @@ -652,9 +741,7 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.config.as_mut().unwrap().dnssec = None; - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); // Default behavior when no DNSSEC config is provided @@ -672,7 +759,8 @@ mod tests { image_pull_secrets: None, }); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; assert_eq!( @@ -693,7 +781,8 @@ mod tests { image_pull_secrets: Some(vec!["secret1".to_string(), "secret2".to_string()]), }); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let secrets = pod_spec.image_pull_secrets.unwrap(); @@ -707,7 +796,8 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.replicas = None; - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); // Should default to 1 replica assert_eq!(deployment.spec.unwrap().replicas, Some(1)); @@ -718,7 +808,8 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.replicas = Some(0); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); assert_eq!(deployment.spec.unwrap().replicas, Some(0)); } @@ -737,7 +828,8 @@ mod tests { ..Default::default() }]); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let volumes = deployment .spec .unwrap() @@ -766,7 +858,8 @@ mod tests { ..Default::default() }]); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let mounts = pod_spec.containers[0].volume_mounts.as_ref().unwrap(); @@ -778,7 +871,8 @@ mod tests { #[test] fn test_deployment_container_has_correct_command() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; // Verify named command with correct flags @@ -792,7 +886,8 @@ mod tests { #[test] fn test_deployment_has_tz_environment_variable() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; let env = container.env.as_ref().unwrap(); @@ -805,7 +900,8 @@ mod tests { use crate::constants::BIND9_MALLOC_CONF; let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; let env = container.env.as_ref().unwrap(); @@ -816,7 +912,8 @@ mod tests { #[test] fn test_deployment_probe_configuration() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; // Liveness probe @@ -895,9 +992,7 @@ mod tests { "172.16.0.0/12".to_string(), ]); - let cm = build_configmap("test", "test-ns", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); assert!(options.contains("allow-query { 10.0.0.0/8; 192.168.0.0/16; 172.16.0.0/12; }")); @@ -906,7 +1001,14 @@ mod tests { #[test] fn test_deployment_with_custom_namespace() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "custom-namespace", &instance, None, None); + let deployment = build_deployment( + "test", + "custom-namespace", + &instance, + None, + None, + "test-rndc-key", + ); assert_eq!( deployment.metadata.namespace.as_deref(), @@ -928,9 +1030,7 @@ mod tests { #[test] fn test_configmap_with_custom_namespace() { let instance = create_test_instance("test"); - let cm = build_configmap("test", "custom-namespace", &instance, None, None) - .unwrap() - .unwrap(); + let cm = build_configmap("test", "custom-namespace", &instance, None, None).unwrap(); assert_eq!(cm.metadata.namespace.as_deref(), Some("custom-namespace")); } @@ -940,7 +1040,8 @@ mod tests { let mut instance = create_test_instance("test"); instance.spec.version = None; - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; // Should default to 9.18 @@ -962,7 +1063,8 @@ mod tests { image_pull_secrets: None, }); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; // Custom image should override version @@ -981,7 +1083,8 @@ mod tests { image_pull_secrets: None, }); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; // Should use version from spec @@ -995,7 +1098,8 @@ mod tests { #[test] fn test_deployment_default_image_pull_policy() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let container = &deployment.spec.unwrap().template.spec.unwrap().containers[0]; assert_eq!(container.image_pull_policy.as_deref(), Some("IfNotPresent")); @@ -1134,7 +1238,14 @@ mod tests { #[test] fn test_deployment_rndc_conf_volume_mount() { let instance = create_test_instance("rndc-test"); - let deployment = build_deployment("rndc-test", "test-ns", &instance, None, None); + let deployment = build_deployment( + "rndc-test", + "test-ns", + &instance, + None, + None, + "rndc-test-rndc-key", + ); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let container = &pod_spec.containers[0]; @@ -1155,10 +1266,7 @@ mod tests { #[test] fn test_configmap_contains_rndc_conf() { let instance = create_test_instance("rndc-test"); - let configmap = build_configmap("rndc-test", "test-ns", &instance, None, None).unwrap(); - - assert!(configmap.is_some()); - let cm = configmap.unwrap(); + let cm = build_configmap("rndc-test", "test-ns", &instance, None, None).unwrap(); let data = cm.data.unwrap(); // Verify rndc.conf is present in ConfigMap @@ -1178,7 +1286,14 @@ mod tests { #[test] fn test_rndc_key_volume_mount() { let instance = create_test_instance("rndc-test"); - let deployment = build_deployment("rndc-test", "test-ns", &instance, None, None); + let deployment = build_deployment( + "rndc-test", + "test-ns", + &instance, + None, + None, + "rndc-test-rndc-key", + ); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let container = &pod_spec.containers[0]; @@ -1197,7 +1312,14 @@ mod tests { #[test] fn test_rndc_key_volume_source() { let instance = create_test_instance("rndc-test"); - let deployment = build_deployment("rndc-test", "test-ns", &instance, None, None); + let deployment = build_deployment( + "rndc-test", + "test-ns", + &instance, + None, + None, + "rndc-test-rndc-key", + ); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let volumes = pod_spec.volumes.unwrap(); @@ -1219,6 +1341,265 @@ mod tests { ); } + #[test] + fn test_build_deployment_uses_resolved_rndc_secret_name() { + // When the reconciler resolves an rndcKey.secretRef (or inline secret + // name), that resolved name must flow into BOTH the rndc-key volume + // and the bindcar env secretKeyRefs — not the auto-generated + // `{name}-rndc-key` default. + let instance = create_test_instance("rndc-test"); + let deployment = build_deployment( + "rndc-test", + "test-ns", + &instance, + None, + None, + "user-managed-rndc-secret", + ); + + let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); + + // Volume must reference the resolved Secret name. + let rndc_volume = pod_spec + .volumes + .as_ref() + .unwrap() + .iter() + .find(|v| v.name == "rndc-key") + .expect("rndc-key volume must exist"); + assert_eq!( + rndc_volume.secret.as_ref().unwrap().secret_name.as_deref(), + Some("user-managed-rndc-secret"), + "rndc-key volume must use the resolved Secret name" + ); + + // bindcar env secretKeyRefs must reference the resolved Secret name. + let api = pod_spec + .containers + .iter() + .find(|c| c.name == "api") + .expect("api sidecar must exist"); + for env_name in ["RNDC_SECRET", "RNDC_ALGORITHM"] { + let env = api + .env + .as_ref() + .unwrap() + .iter() + .find(|e| e.name == env_name) + .unwrap_or_else(|| panic!("{env_name} env var must exist")); + let secret_ref = env + .value_from + .as_ref() + .and_then(|v| v.secret_key_ref.as_ref()) + .unwrap_or_else(|| panic!("{env_name} must use a secretKeyRef")); + assert_eq!( + secret_ref.name, "user-managed-rndc-secret", + "{env_name} secretKeyRef must use the resolved Secret name" + ); + } + } + + // ===================================================== + // forwarders / listenOn / listenOnV6 rendering + // ===================================================== + + #[test] + fn test_options_conf_default_listen_on_any_and_no_forwarders() { + let instance = create_test_instance("test"); + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + options.contains("listen-on port 53 { any; };"), + "default listen-on must be `any` on port 53, got: {options}" + ); + assert!( + options.contains("listen-on-v6 port 53 { any; };"), + "default listen-on-v6 must be `any` on port 53, got: {options}" + ); + assert!( + !options.contains("forwarders"), + "no forwarders block must be rendered when unset, got: {options}" + ); + } + + #[test] + fn test_options_conf_renders_forwarders() { + let mut instance = create_test_instance("test"); + instance.spec.config.as_mut().unwrap().forwarders = + Some(vec!["8.8.8.8".to_string(), "8.8.4.4".to_string()]); + + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + options.contains("forwarders { 8.8.8.8; 8.8.4.4; };"), + "forwarders block must be rendered from spec.config.forwarders, got: {options}" + ); + } + + #[test] + fn test_options_conf_empty_forwarders_renders_nothing() { + let mut instance = create_test_instance("test"); + instance.spec.config.as_mut().unwrap().forwarders = Some(vec![]); + + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + !options.contains("forwarders"), + "empty forwarders list must render nothing, got: {options}" + ); + } + + #[test] + fn test_options_conf_rejects_invalid_forwarder() { + // A forwarder that is not a plain IP address (e.g. a config-injection + // payload) must be rejected, not rendered into named.conf.options. + let mut instance = create_test_instance("test"); + instance.spec.config.as_mut().unwrap().forwarders = + Some(vec!["8.8.8.8; }; zone \"evil\" {".to_string()]); + + let result = build_configmap("test", "test-ns", &instance, None, None); + assert!(result.is_err(), "invalid forwarder must be rejected"); + } + + #[test] + fn test_options_conf_renders_custom_listen_on() { + let mut instance = create_test_instance("test"); + { + let config = instance.spec.config.as_mut().unwrap(); + config.listen_on = Some(vec!["10.0.0.1".to_string(), "127.0.0.1".to_string()]); + config.listen_on_v6 = Some(vec!["::1".to_string()]); + } + + let cm = build_configmap("test", "test-ns", &instance, None, None).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + options.contains("listen-on port 53 { 10.0.0.1; 127.0.0.1; };"), + "listen-on must render spec.config.listenOn, got: {options}" + ); + assert!( + options.contains("listen-on-v6 port 53 { ::1; };"), + "listen-on-v6 must render spec.config.listenOnV6, got: {options}" + ); + assert!( + !options.contains("listen-on port 53 { any; };"), + "default listen-on must be replaced when listenOn is set, got: {options}" + ); + } + + #[test] + fn test_options_conf_rejects_invalid_listen_on_entry() { + let mut instance = create_test_instance("test"); + instance.spec.config.as_mut().unwrap().listen_on = + Some(vec!["any; }; zone \"evil\" {".to_string()]); + + let result = build_configmap("test", "test-ns", &instance, None, None); + assert!(result.is_err(), "invalid listen-on entry must be rejected"); + } + + #[test] + fn test_cluster_options_conf_renders_forwarders_and_listen_on() { + use crate::bind9_resources::build_cluster_configmap; + use crate::crd::{Bind9Cluster, Bind9ClusterCommonSpec, Bind9ClusterSpec}; + + let cluster = Bind9Cluster { + metadata: ObjectMeta { + name: Some("test-cluster".to_string()), + namespace: Some("test-ns".to_string()), + ..Default::default() + }, + spec: Bind9ClusterSpec { + common: Bind9ClusterCommonSpec { + version: None, + primary: None, + secondary: None, + image: None, + config_map_refs: None, + global: Some(Bind9Config { + recursion: Some(true), + allow_query: None, + allow_transfer: None, + dnssec: None, + forwarders: Some(vec!["1.1.1.1".to_string()]), + listen_on: Some(vec!["10.1.2.3".to_string()]), + listen_on_v6: Some(vec!["none".to_string()]), + rndc_secret_ref: None, + bindcar_config: None, + }), + rndc_secret_refs: None, + acls: None, + volumes: None, + volume_mounts: None, + }, + }, + status: None, + }; + + let cm = build_cluster_configmap("test-cluster", "test-ns", &cluster).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + options.contains("forwarders { 1.1.1.1; };"), + "cluster-level forwarders must be rendered, got: {options}" + ); + assert!( + options.contains("listen-on port 53 { 10.1.2.3; };"), + "cluster-level listen-on must be rendered, got: {options}" + ); + assert!( + options.contains("listen-on-v6 port 53 { none; };"), + "cluster-level listen-on-v6 must be rendered, got: {options}" + ); + } + + #[test] + fn test_cluster_options_conf_default_listen_on_any() { + use crate::bind9_resources::build_cluster_configmap; + use crate::crd::{Bind9Cluster, Bind9ClusterCommonSpec, Bind9ClusterSpec}; + + let cluster = Bind9Cluster { + metadata: ObjectMeta { + name: Some("test-cluster".to_string()), + namespace: Some("test-ns".to_string()), + ..Default::default() + }, + spec: Bind9ClusterSpec { + common: Bind9ClusterCommonSpec { + version: None, + primary: None, + secondary: None, + image: None, + config_map_refs: None, + global: None, + rndc_secret_refs: None, + acls: None, + volumes: None, + volume_mounts: None, + }, + }, + status: None, + }; + + let cm = build_cluster_configmap("test-cluster", "test-ns", &cluster).unwrap(); + let options = cm.data.unwrap().get("named.conf.options").unwrap().clone(); + + assert!( + options.contains("listen-on port 53 { any; };"), + "cluster default listen-on must be `any`, got: {options}" + ); + assert!( + options.contains("listen-on-v6 port 53 { any; };"), + "cluster default listen-on-v6 must be `any`, got: {options}" + ); + assert!( + !options.contains("forwarders"), + "no forwarders block by default, got: {options}" + ); + } + #[test] fn test_service_api_port_default() { // Test default API port (8080) when no bindcar_config is specified @@ -1332,7 +1713,8 @@ mod tests { // Test that deployment includes the API sidecar container let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let containers = pod_spec.containers; @@ -1374,7 +1756,8 @@ mod tests { // Test that API sidecar mounts the same volumes as BIND9 let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let containers = pod_spec.containers; @@ -1425,7 +1808,8 @@ mod tests { // still dropping ALL others and running with a RuntimeDefault seccomp // profile and a non-root UID. let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let named = pod_spec .containers @@ -1461,7 +1845,8 @@ mod tests { // posture: drop ALL (no add), read-only root filesystem, RuntimeDefault // seccomp, non-root. let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let api = pod_spec .containers @@ -1485,7 +1870,8 @@ mod tests { #[test] fn test_pod_security_context_has_seccomp() { let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let sc = pod_spec.security_context.as_ref().unwrap(); assert_eq!(sc.run_as_non_root, Some(true)); @@ -1499,7 +1885,8 @@ mod tests { // token, so BIND_TOKEN_AUDIENCES must match. TMPDIR must point at the // writable /tmp mount. let instance = create_test_instance("test"); - let deployment = build_deployment("test", "test-ns", &instance, None, None); + let deployment = + build_deployment("test", "test-ns", &instance, None, None, "test-rndc-key"); let pod_spec = deployment.spec.unwrap().template.spec.unwrap(); let api = pod_spec .containers @@ -1637,7 +2024,12 @@ mod tests { result.contains("lifetime unlimited"), "Should use unlimited lifetime by default" ); - assert!(result.contains("nsec;"), "Should use NSEC by default"); + // BIND 9.18 has no `nsec` statement in dnssec-policy: NSEC is selected + // by omitting `nsec3param` entirely. + assert!( + !result.contains("nsec"), + "NSEC default must be expressed by omitting nsec3param, got: {result}" + ); } #[test] @@ -1690,9 +2082,65 @@ mod tests { result.contains("zsk lifetime 90d"), "Should use custom ZSK lifetime" ); + // NSEC (nsec3: false) must render no `nsec`/`nsec3param` statement at + // all — `nsec;` is not valid BIND 9.18 dnssec-policy grammar. + assert!( + !result.contains("nsec"), + "NSEC must be expressed by omitting nsec3param, got: {result}" + ); + } + + #[test] + fn test_dnssec_policies_falls_back_to_global_when_instance_has_no_dnssec() { + use crate::bind9_resources::generate_dnssec_policies; + + // Global config enables signing. + let global_config = Bind9Config { + recursion: Some(false), + allow_query: None, + allow_transfer: None, + dnssec: Some(DNSSECConfig { + validation: Some(true), + signing: Some(crate::crd::DNSSECSigningConfig { + enabled: true, + policy: Some("global-policy".to_string()), + algorithm: None, + ksk_lifetime: None, + zsk_lifetime: None, + nsec3: None, + nsec3_salt: None, + nsec3_iterations: None, + keys_from: None, + auto_generate: None, + export_to_secret: None, + }), + }), + forwarders: None, + listen_on: None, + listen_on_v6: None, + rndc_secret_ref: None, + bindcar_config: None, + }; + + // Instance config exists but carries no dnssec block at all. + let instance_config = Bind9Config { + recursion: Some(true), + allow_query: None, + allow_transfer: None, + dnssec: None, + forwarders: None, + listen_on: None, + listen_on_v6: None, + rndc_secret_ref: None, + bindcar_config: None, + }; + + let result = generate_dnssec_policies(Some(&global_config), Some(&instance_config)); + + // Must fall back to global config, matching get_dnssec_signing_config. assert!( - result.contains("nsec;"), - "Should use NSEC when nsec3 is false" + result.contains("dnssec-policy \"global-policy\""), + "should fall back to global DNSSEC config when instance config has no dnssec block, got: {result}" ); } diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 6bd52c79..e938c41a 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -10,8 +10,14 @@ //! 3. ServiceAccount (`bindy`) //! 4. ClusterRole (`bindy-role`) — operator permissions //! 5. ClusterRole (`bindy-admin-role`) — admin/destructive permissions -//! 6. ClusterRoleBinding (`bindy-rolebinding`) — binds SA to operator role -//! 7. Deployment (`bindy`) — the operator itself +//! 6. ClusterRole (`bindcar-tokenreview`) — `create tokenreviews` for the bindcar sidecar +//! 7. ClusterRoleBinding (`bindy-rolebinding`) — binds SA to operator role +//! 8. ClusterRoleBinding (`bindcar-tokenreview`) — binds the operand `bind9` SA +//! (in the requested `--namespace`) to the TokenReview ClusterRole +//! 9. Role + RoleBinding (`bindy-secrets-writer`) — namespaced Secret write access +//! 10. Deployment (`bindy`) — the operator itself, with a projected SA token +//! (`audience: bindcar`) and `POD_NAMESPACE` from the downward API so it can +//! authenticate to bindcar 0.7.0 sidecars //! //! ## `bindy bootstrap scout` //! Applies all scout prerequisites to a Kubernetes cluster in order: @@ -110,6 +116,61 @@ pub const DEFAULT_IMAGE_TAG: &str = concat!("v", env!("CARGO_PKG_VERSION")); pub const BINDY_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role.yaml"); pub const BINDY_ADMIN_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role-admin.yaml"); +/// Embedded TokenReview ClusterRole (bindcar `0.7.0` Mode B). +/// +/// Grants `create tokenreviews` so the bindcar sidecar (running as the operand +/// `bind9` ServiceAccount) can validate the operator's bearer token against the +/// API server. Mirrors `deploy/operator/rbac/tokenreview-clusterrole.yaml`. +pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML: &str = + include_str!("../deploy/operator/rbac/tokenreview-clusterrole.yaml"); + +/// Embedded TokenReview ClusterRoleBinding (bindcar `0.7.0` Mode B). +/// +/// The static manifest binds the operand `bind9` ServiceAccount in +/// `bindy-system`; the bootstrap path rewrites the subject namespace to the +/// requested `--namespace` via [`build_tokenreview_cluster_role_binding`]. +pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML: &str = + include_str!("../deploy/operator/rbac/tokenreview-clusterrolebinding.yaml"); + +/// Name shared by the TokenReview ClusterRole and ClusterRoleBinding. +pub const BINDCAR_TOKENREVIEW_NAME: &str = "bindcar-tokenreview"; + +/// Embedded ClusterRole manifests applied by `bindy bootstrap operator`, in apply order. +pub const OPERATOR_CLUSTER_ROLE_YAMLS: &[&str] = &[ + BINDY_ROLE_YAML, + BINDY_ADMIN_ROLE_YAML, + BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML, +]; + +/// Volume name for the projected ServiceAccount token carrying the `bindcar` audience. +/// +/// Mirrors the `bindcar-token` volume in `deploy/operator/deployment.yaml`. +pub const BINDCAR_TOKEN_VOLUME_NAME: &str = "bindcar-token"; + +/// Mount path of the projected bindcar token volume inside the operator container. +/// +/// Together with [`BINDCAR_TOKEN_FILENAME`] this composes +/// [`crate::bind9::BINDCAR_TOKEN_PATH`] (`/var/run/secrets/bindcar/token`), +/// the path the operator reads the bearer token from. +pub const BINDCAR_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/bindcar"; + +/// File name of the projected token inside [`BINDCAR_TOKEN_MOUNT_PATH`]. +pub const BINDCAR_TOKEN_FILENAME: &str = "token"; + +/// Expiration (seconds) for the projected bindcar token. +/// +/// The kubelet automatically rotates the token before expiry; mirrors +/// `expirationSeconds: 3600` in `deploy/operator/deployment.yaml`. +pub const BINDCAR_TOKEN_EXPIRATION_SECONDS: i64 = 3600; + +/// Downward-API environment variable carrying the operator pod's namespace. +/// +/// `src/bind9_resources.rs` reads this to compose the +/// `BIND_ALLOWED_SERVICE_ACCOUNTS` value for operand bindcar sidecars; without +/// it the operator falls back to `bindy-system` and bindcar rejects tokens +/// from operators installed in any other namespace. +pub const POD_NAMESPACE_ENV: &str = "POD_NAMESPACE"; + // --------------------------------------------------------------------------- // Scout constants // --------------------------------------------------------------------------- @@ -257,9 +318,11 @@ pub async fn run_bootstrap_operator( apply_namespace(&client, namespace).await?; apply_crds(&client).await?; apply_service_account(&client, namespace).await?; - apply_cluster_role(&client, BINDY_ROLE_YAML).await?; - apply_cluster_role(&client, BINDY_ADMIN_ROLE_YAML).await?; + for yaml in OPERATOR_CLUSTER_ROLE_YAMLS { + apply_cluster_role(&client, yaml).await?; + } apply_cluster_role_binding(&client, namespace).await?; + apply_tokenreview_cluster_role_binding(&client, namespace).await?; apply_secrets_writer_role(&client, namespace).await?; apply_secrets_writer_role_binding(&client, namespace).await?; apply_deployment(&client, namespace, image_tag, registry).await?; @@ -327,15 +390,16 @@ fn run_operator_dry_run(namespace: &str, image_tag: &str, registry: Option<&str> } print_resource("ServiceAccount", &build_service_account(namespace))?; + for yaml in OPERATOR_CLUSTER_ROLE_YAMLS { + let role = parse_cluster_role(yaml)?; + let name = role.metadata.name.clone().unwrap_or_default(); + print_resource(&format!("ClusterRole ({name})"), &role)?; + } + print_resource("ClusterRoleBinding", &build_cluster_role_binding(namespace))?; print_resource( - "ClusterRole (operator)", - &parse_cluster_role(BINDY_ROLE_YAML)?, - )?; - print_resource( - "ClusterRole (admin)", - &parse_cluster_role(BINDY_ADMIN_ROLE_YAML)?, + "ClusterRoleBinding (bindcar-tokenreview)", + &build_tokenreview_cluster_role_binding(namespace)?, )?; - print_resource("ClusterRoleBinding", &build_cluster_role_binding(namespace))?; print_resource( "Role (secrets-writer)", &build_secrets_writer_role(namespace), @@ -471,6 +535,22 @@ async fn apply_cluster_role_binding(client: &Client, namespace: &str) -> Result< Ok(()) } +/// Apply the TokenReview ClusterRoleBinding, re-homing the subject namespace +/// to the requested install namespace (see [`build_tokenreview_cluster_role_binding`]). +async fn apply_tokenreview_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> { + let api: Api = Api::all(client.clone()); + let crb = build_tokenreview_cluster_role_binding(namespace)?; + api.patch( + BINDCAR_TOKENREVIEW_NAME, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(&crb), + ) + .await + .with_context(|| format!("Failed to apply ClusterRoleBinding/{BINDCAR_TOKENREVIEW_NAME}"))?; + println!("✓ ClusterRoleBinding: {BINDCAR_TOKENREVIEW_NAME} (subject namespace: {namespace})"); + Ok(()) +} + async fn apply_secrets_writer_role(client: &Client, namespace: &str) -> Result<()> { let api: Api = Api::namespaced(client.clone(), namespace); let role = build_secrets_writer_role(namespace); @@ -784,6 +864,9 @@ pub fn build_deployment( "env": [ {"name": "RUST_LOG", "value": "info"}, {"name": "RUST_LOG_FORMAT", "value": "text"}, + // Downward API: the operator derives BIND_ALLOWED_SERVICE_ACCOUNTS + // for operand bindcar sidecars from its own namespace. + {"name": POD_NAMESPACE_ENV, "valueFrom": {"fieldRef": {"fieldPath": "metadata.namespace"}}}, {"name": "BINDY_ENABLE_LEADER_ELECTION", "value": "true"}, {"name": "BINDY_LEASE_NAME", "value": "bindy-leader"} ], @@ -798,9 +881,34 @@ pub fn build_deployment( "limits": {"cpu": "500m", "memory": "512Mi"}, "requests": {"cpu": "100m", "memory": "128Mi"} }, - "volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}] + "volumeMounts": [ + {"name": "tmp", "mountPath": "/tmp"}, + // bindcar 0.7.0 (Mode B / TokenReview) bearer token, + // read from /var/run/secrets/bindcar/token. + { + "name": BINDCAR_TOKEN_VOLUME_NAME, + "mountPath": BINDCAR_TOKEN_MOUNT_PATH, + "readOnly": true + } + ] }], - "volumes": [{"name": "tmp", "emptyDir": {}}] + "volumes": [ + {"name": "tmp", "emptyDir": {}}, + // Short-lived SA token minted with the `bindcar` audience so + // bindcar 0.7.0 accepts it (default BIND_TOKEN_AUDIENCES is `bindcar`). + { + "name": BINDCAR_TOKEN_VOLUME_NAME, + "projected": { + "sources": [{ + "serviceAccountToken": { + "audience": crate::constants::BINDCAR_TOKEN_AUDIENCE, + "expirationSeconds": BINDCAR_TOKEN_EXPIRATION_SECONDS, + "path": BINDCAR_TOKEN_FILENAME + } + }] + } + } + ] } } } @@ -813,6 +921,40 @@ pub fn parse_cluster_role(yaml: &str) -> Result { serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML") } +/// Parse a ClusterRoleBinding from embedded YAML. +pub fn parse_cluster_role_binding(yaml: &str) -> Result { + serde_yaml::from_str(yaml).context("Failed to parse ClusterRoleBinding YAML") +} + +/// Build the TokenReview ClusterRoleBinding for the requested install namespace. +/// +/// Parses the embedded manifest ([`BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML`]) +/// and rewrites every subject's namespace to `namespace`. The static manifest +/// hardcodes `bindy-system`, but `bindy bootstrap operator --namespace foo` +/// creates the operand `bind9` ServiceAccount in `foo`, so the binding must +/// follow the requested namespace or bindcar's TokenReview calls are denied. +/// +/// # Arguments +/// * `namespace` - Namespace the operator (and operand `bind9` SA) is installed into +/// +/// # Errors +/// Returns an error if the embedded YAML fails to parse or has no subjects. +pub fn build_tokenreview_cluster_role_binding(namespace: &str) -> Result { + let mut crb = parse_cluster_role_binding(BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML)?; + + let Some(subjects) = crb.subjects.as_mut() else { + return Err(anyhow!( + "Embedded TokenReview ClusterRoleBinding has no subjects" + )); + }; + + for subject in subjects { + subject.namespace = Some(namespace.to_string()); + } + + Ok(crb) +} + /// Build a single CRD from a Rust type, ensuring `storage: true` and `served: true`. /// /// Mirrors the logic in `src/bin/crdgen.rs` so bootstrap and crdgen stay in sync. diff --git a/src/bootstrap_tests.rs b/src/bootstrap_tests.rs index 3e5d6059..2adaf1df 100644 --- a/src/bootstrap_tests.rs +++ b/src/bootstrap_tests.rs @@ -10,14 +10,17 @@ mod tests { build_scout_cluster_role, build_scout_cluster_role_binding, build_scout_deployment, build_scout_service_account, build_scout_writer_role, build_scout_writer_role_binding, build_secrets_writer_role, build_secrets_writer_role_binding, build_service_account, - parse_cluster_role, resolve_image, ScoutDeploymentOptions, BINDY_ADMIN_ROLE_YAML, - BINDY_ROLE_YAML, CLUSTER_ROLE_BINDING_NAME, DEFAULT_IMAGE_TAG, DEFAULT_NAMESPACE, - DEFAULT_SCOUT_CLUSTER_NAME, MC_DEFAULT_SERVICE_ACCOUNT_NAME, OPERATOR_DEPLOYMENT_NAME, - OPERATOR_IMAGE_BASE, OPERATOR_ROLE_NAME, REMOTE_KUBECONFIG_SECRET_SUFFIX, - REMOTE_KUBECONFIG_SECRET_TYPE, SA_TOKEN_SECRET_SUFFIX, SCOUT_CLUSTER_ROLE_BINDING_NAME, - SCOUT_CLUSTER_ROLE_NAME, SCOUT_DEPLOYMENT_NAME, SCOUT_SERVICE_ACCOUNT_NAME, - SCOUT_WRITER_ROLE_BINDING_NAME, SCOUT_WRITER_ROLE_NAME, SECRETS_WRITER_ROLE_BINDING_NAME, - SECRETS_WRITER_ROLE_NAME, SERVICE_ACCOUNT_NAME, + build_tokenreview_cluster_role_binding, parse_cluster_role, resolve_image, + ScoutDeploymentOptions, BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML, BINDCAR_TOKENREVIEW_NAME, + BINDCAR_TOKEN_EXPIRATION_SECONDS, BINDCAR_TOKEN_FILENAME, BINDCAR_TOKEN_MOUNT_PATH, + BINDCAR_TOKEN_VOLUME_NAME, BINDY_ADMIN_ROLE_YAML, BINDY_ROLE_YAML, + CLUSTER_ROLE_BINDING_NAME, DEFAULT_IMAGE_TAG, DEFAULT_NAMESPACE, + DEFAULT_SCOUT_CLUSTER_NAME, MC_DEFAULT_SERVICE_ACCOUNT_NAME, OPERATOR_CLUSTER_ROLE_YAMLS, + OPERATOR_DEPLOYMENT_NAME, OPERATOR_IMAGE_BASE, OPERATOR_ROLE_NAME, POD_NAMESPACE_ENV, + REMOTE_KUBECONFIG_SECRET_SUFFIX, REMOTE_KUBECONFIG_SECRET_TYPE, SA_TOKEN_SECRET_SUFFIX, + SCOUT_CLUSTER_ROLE_BINDING_NAME, SCOUT_CLUSTER_ROLE_NAME, SCOUT_DEPLOYMENT_NAME, + SCOUT_SERVICE_ACCOUNT_NAME, SCOUT_WRITER_ROLE_BINDING_NAME, SCOUT_WRITER_ROLE_NAME, + SECRETS_WRITER_ROLE_BINDING_NAME, SECRETS_WRITER_ROLE_NAME, SERVICE_ACCOUNT_NAME, }; /// Convenience helper: build a minimal `ScoutDeploymentOptions` for tests. @@ -298,6 +301,170 @@ mod tests { assert!(!DEFAULT_IMAGE_TAG.is_empty()); } + // --- bindcar 0.7.0 TokenReview (Mode B) support in the bootstrap path --- + + /// Convenience helper: extract the operator PodSpec from a built Deployment. + fn operator_pod_spec(namespace: &str) -> k8s_openapi::api::core::v1::PodSpec { + build_deployment(namespace, "latest", None) + .unwrap() + .spec + .unwrap() + .template + .spec + .unwrap() + } + + #[test] + fn test_build_deployment_has_bindcar_token_projected_volume() { + let pod_spec = operator_pod_spec("foo"); + let volumes = pod_spec.volumes.expect("pod must have volumes"); + let volume = volumes + .iter() + .find(|v| v.name == BINDCAR_TOKEN_VOLUME_NAME) + .expect("bindcar-token projected volume must exist"); + let sources = volume + .projected + .as_ref() + .expect("bindcar-token volume must be projected") + .sources + .as_ref() + .expect("projected volume must have sources"); + let sa_token = sources + .iter() + .find_map(|s| s.service_account_token.as_ref()) + .expect("projected volume must have a serviceAccountToken source"); + assert_eq!( + sa_token.audience.as_deref(), + Some(crate::constants::BINDCAR_TOKEN_AUDIENCE), + "token audience must be `bindcar` so bindcar 0.7.0 accepts it" + ); + assert_eq!( + sa_token.expiration_seconds, + Some(BINDCAR_TOKEN_EXPIRATION_SECONDS), + "token expiration must use the named constant" + ); + assert_eq!(sa_token.path, BINDCAR_TOKEN_FILENAME); + } + + #[test] + fn test_build_deployment_mounts_bindcar_token_read_only() { + let pod_spec = operator_pod_spec("foo"); + let container = pod_spec.containers.into_iter().next().unwrap(); + let mounts = container.volume_mounts.expect("container must have mounts"); + let mount = mounts + .iter() + .find(|m| m.name == BINDCAR_TOKEN_VOLUME_NAME) + .expect("bindcar-token volumeMount must exist"); + assert_eq!(mount.mount_path, BINDCAR_TOKEN_MOUNT_PATH); + assert_eq!( + mount.read_only, + Some(true), + "bindcar token mount must be read-only" + ); + } + + /// The mount path plus token file name must compose to the exact path the + /// operator reads the bearer token from (`BINDCAR_TOKEN_PATH`). + #[test] + fn test_bindcar_token_mount_path_matches_operator_token_path() { + assert_eq!( + format!("{BINDCAR_TOKEN_MOUNT_PATH}/{BINDCAR_TOKEN_FILENAME}"), + crate::bind9::BINDCAR_TOKEN_PATH + ); + } + + /// POD_NAMESPACE must come from the downward API so the operator stamps the + /// correct `system:serviceaccount::bindy` into operand pods' + /// BIND_ALLOWED_SERVICE_ACCOUNTS regardless of the `--namespace` argument. + #[test] + fn test_build_deployment_has_pod_namespace_downward_api_env() { + let pod_spec = operator_pod_spec("custom-ns"); + let container = pod_spec.containers.into_iter().next().unwrap(); + let env = container.env.expect("container must have env vars"); + let pod_namespace = env + .iter() + .find(|e| e.name == POD_NAMESPACE_ENV) + .expect("POD_NAMESPACE env var must exist"); + let field_path = pod_namespace + .value_from + .as_ref() + .and_then(|v| v.field_ref.as_ref()) + .map(|f| f.field_path.as_str()); + assert_eq!( + field_path, + Some("metadata.namespace"), + "POD_NAMESPACE must use the downward API (fieldRef metadata.namespace)" + ); + } + + #[test] + fn test_tokenreview_cluster_role_is_applied_by_operator_bootstrap() { + let names: Vec = OPERATOR_CLUSTER_ROLE_YAMLS + .iter() + .map(|yaml| { + parse_cluster_role(yaml) + .unwrap() + .metadata + .name + .expect("embedded ClusterRole must have a name") + }) + .collect(); + assert!( + names.iter().any(|n| n == BINDCAR_TOKENREVIEW_NAME), + "operator bootstrap must apply the TokenReview ClusterRole, got: {names:?}" + ); + } + + #[test] + fn test_tokenreview_cluster_role_grants_only_create_tokenreviews() { + let role = parse_cluster_role(BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML).unwrap(); + assert_eq!( + role.metadata.name.as_deref(), + Some(BINDCAR_TOKENREVIEW_NAME) + ); + let rules = role.rules.expect("TokenReview ClusterRole must have rules"); + assert_eq!(rules.len(), 1, "least-privilege: exactly one rule"); + let rule = &rules[0]; + assert_eq!( + rule.api_groups.as_deref(), + Some(&["authentication.k8s.io".to_string()][..]) + ); + assert_eq!( + rule.resources.as_deref(), + Some(&["tokenreviews".to_string()][..]) + ); + assert_eq!(rule.verbs, vec!["create".to_string()]); + } + + #[test] + fn test_build_tokenreview_cluster_role_binding_role_ref() { + let crb = build_tokenreview_cluster_role_binding(DEFAULT_NAMESPACE).unwrap(); + assert_eq!(crb.metadata.name.as_deref(), Some(BINDCAR_TOKENREVIEW_NAME)); + assert_eq!(crb.role_ref.kind, "ClusterRole"); + assert_eq!(crb.role_ref.name, BINDCAR_TOKENREVIEW_NAME); + } + + #[test] + fn test_build_tokenreview_cluster_role_binding_subject_is_operand_bind9_sa() { + let crb = build_tokenreview_cluster_role_binding(DEFAULT_NAMESPACE).unwrap(); + let subject = crb.subjects.unwrap().into_iter().next().unwrap(); + assert_eq!(subject.kind, "ServiceAccount"); + assert_eq!(subject.name, crate::constants::BIND9_SERVICE_ACCOUNT); + assert_eq!(subject.namespace.as_deref(), Some(DEFAULT_NAMESPACE)); + } + + /// The binding subject namespace must follow the requested `--namespace` + /// argument, not the hardcoded `bindy-system` from the embedded manifest. + #[test] + fn test_build_tokenreview_cluster_role_binding_follows_requested_namespace() { + let crb = build_tokenreview_cluster_role_binding("custom-ns").unwrap(); + let subjects = crb.subjects.unwrap(); + assert!(!subjects.is_empty()); + for subject in subjects { + assert_eq!(subject.namespace.as_deref(), Some("custom-ns")); + } + } + #[test] fn test_default_image_tag_format() { // In debug builds it must be "latest"; in release builds it must start with "v" diff --git a/src/crd.rs b/src/crd.rs index 07356cb2..d49b64b8 100644 --- a/src/crd.rs +++ b/src/crd.rs @@ -1767,6 +1767,15 @@ pub struct RecordStatus { /// For other record types, this field is not used. #[serde(skip_serializing_if = "Option::is_none")] pub addresses: Option, + /// DNS record name (from `spec.name`) most recently published to BIND9. + /// + /// Set by the record reconciler after a successful dynamic DNS update. + /// When `spec.name` changes (a rename), the reconciler compares it against + /// this field, deletes the old FQDN from the zone, publishes the new name, + /// and then updates this field. Without it, renamed records would leave + /// their old FQDN orphaned in BIND9. + #[serde(skip_serializing_if = "Option::is_none")] + pub published_name: Option, } /// RNDC/TSIG algorithm for authenticated communication and zone transfers. @@ -3359,6 +3368,19 @@ pub struct Bind9InstanceStatus { pub conditions: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub observed_generation: Option, + /// Generation of the referenced parent cluster that was last reconciled. + /// + /// Records the `metadata.generation` of the parent `Bind9Cluster` or + /// `ClusterBind9Provider` (whichever this instance references) as observed + /// during the last successful reconciliation. The instance reconciler + /// compares the parent's current generation against this value to detect + /// parent configuration changes (e.g., RNDC config added at the cluster + /// level) that must be propagated to the instance. + /// + /// This is intentionally separate from `observed_generation`, which tracks + /// the instance's OWN spec generation - the two counters are unrelated. + #[serde(skip_serializing_if = "Option::is_none")] + pub observed_parent_generation: Option, /// IP or hostname of this instance's service #[serde(skip_serializing_if = "Option::is_none")] pub service_address: Option, diff --git a/src/crd_tests.rs b/src/crd_tests.rs index 97f70cd8..0c5d629e 100644 --- a/src/crd_tests.rs +++ b/src/crd_tests.rs @@ -386,6 +386,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: vec![condition], observed_generation: Some(1), + observed_parent_generation: None, service_address: Some("my-instance.bindy-system.svc.cluster.local".into()), cluster_ref: None, zones: Vec::new(), @@ -406,6 +407,7 @@ mod tests { let status_empty = Bind9InstanceStatus { conditions: vec![], observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -506,6 +508,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: conditions.clone(), observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -561,6 +564,7 @@ mod tests { record_hash: None, last_updated: None, addresses: None, + published_name: None, }; assert_eq!(status.conditions.len(), 1); @@ -581,6 +585,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: vec![condition], observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -605,6 +610,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: vec![condition], observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -676,6 +682,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: vec![], observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -692,6 +699,7 @@ mod tests { let status = Bind9InstanceStatus { conditions: vec![], observed_generation: Some(5), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), diff --git a/src/main.rs b/src/main.rs index 61d1d1a5..81964573 100644 --- a/src/main.rs +++ b/src/main.rs @@ -761,15 +761,19 @@ fn default_watcher_config() -> Config { Config::default() } -/// Create a semantic watcher configuration. +/// Create a watcher configuration with relaxed list semantics. /// -/// Returns a watcher configuration that only triggers on semantic changes -/// (spec modifications), ignoring status-only updates. This prevents -/// reconciliation loops when controllers update status fields. +/// `any_semantic()` sets `ListSemantic::Any`, which lets the initial LIST be +/// served from any resource version (cheaper on the API server). It does NOT +/// filter watch events: status-only updates still trigger reconciliation. +/// Event-level filtering would require `predicates::generation` on the +/// controller stream, which is deliberately not used here — several +/// controllers depend on observing status updates (e.g. zone record +/// discovery reacts to record status changes). /// /// # Returns /// -/// A `Config` instance configured with semantic filtering. +/// A `Config` instance with `ListSemantic::Any`. #[inline] fn semantic_watcher_config() -> Config { Config::default().any_semantic() diff --git a/src/reconcilers/bind9cluster/status_helpers.rs b/src/reconcilers/bind9cluster/status_helpers.rs index daef9ab7..35bd7dee 100644 --- a/src/reconcilers/bind9cluster/status_helpers.rs +++ b/src/reconcilers/bind9cluster/status_helpers.rs @@ -145,6 +145,74 @@ pub fn calculate_cluster_status( ) } +/// Determines whether the `Bind9Cluster` status patch is needed. +/// +/// Compares the current status against the values about to be written. The +/// patch is needed if any of the following changed: +/// - `instance_count`, `ready_instances`, or the instance name list +/// - `observed_generation` (so spec edits that don't change counts/conditions +/// still advance `observedGeneration` and stop perpetual re-reconciliation) +/// - Any condition's type, status, reason, or message +/// +/// # Arguments +/// +/// * `current` - The status currently stored on the resource (if any) +/// * `conditions` - New conditions about to be written +/// * `instance_count` - New total instance count +/// * `ready_instances` - New ready instance count +/// * `instances` - New instance name list +/// * `generation` - The `metadata.generation` about to be written as `observed_generation` +/// +/// # Returns +/// +/// `true` if the status patch should be applied, `false` if it can be skipped. +#[must_use] +pub fn cluster_status_changed( + current: Option<&Bind9ClusterStatus>, + conditions: &[Condition], + instance_count: i32, + ready_instances: i32, + instances: &[String], + generation: Option, +) -> bool { + let Some(current) = current else { + // No status exists, need to update + return true; + }; + + // Check if counts changed + if current.instance_count != Some(instance_count) + || current.ready_instances != Some(ready_instances) + || current.instances != instances + { + return true; + } + + // Check if the observed generation is behind the generation about to be + // written. Without this, a spec edit that does not change counts or + // conditions never advances observedGeneration, causing should_reconcile() + // to return true on every requeue forever. + if current.observed_generation != generation { + return true; + } + + // Check if any condition changed + if current.conditions.len() != conditions.len() { + return true; + } + + current + .conditions + .iter() + .zip(conditions.iter()) + .any(|(current_cond, new_cond)| { + current_cond.r#type != new_cond.r#type + || current_cond.status != new_cond.status + || current_cond.message != new_cond.message + || current_cond.reason != new_cond.reason + }) +} + /// Update the status of a `Bind9Cluster` with multiple conditions. /// /// Patches the cluster status in Kubernetes if it has changed. @@ -173,36 +241,15 @@ pub(super) async fn update_status( let api: Api = Api::namespaced(client.clone(), &cluster.namespace().unwrap_or_default()); - // Check if status has actually changed - let current_status = &cluster.status; - let status_changed = - if let Some(current) = current_status { - // Check if counts changed - if current.instance_count != Some(instance_count) - || current.ready_instances != Some(ready_instances) - || current.instances != instances - { - true - } else { - // Check if any condition changed - if current.conditions.len() == conditions.len() { - // Compare each condition - current.conditions.iter().zip(conditions.iter()).any( - |(current_cond, new_cond)| { - current_cond.r#type != new_cond.r#type - || current_cond.status != new_cond.status - || current_cond.message != new_cond.message - || current_cond.reason != new_cond.reason - }, - ) - } else { - true - } - } - } else { - // No status exists, need to update - true - }; + // Check if status has actually changed (including observed_generation) + let status_changed = cluster_status_changed( + cluster.status.as_ref(), + &conditions, + instance_count, + ready_instances, + &instances, + cluster.metadata.generation, + ); // Only update if status has changed if !status_changed { diff --git a/src/reconcilers/bind9cluster/status_helpers_tests.rs b/src/reconcilers/bind9cluster/status_helpers_tests.rs index b8854a82..6d612ad3 100644 --- a/src/reconcilers/bind9cluster/status_helpers_tests.rs +++ b/src/reconcilers/bind9cluster/status_helpers_tests.rs @@ -199,4 +199,90 @@ mod tests { // Then: Should patch the status with new values // AND log "Updating Bind9Cluster status: 3 instances, 3 ready" } + + // ======================================================================== + // Status patch decision (cluster_status_changed) + // ======================================================================== + + use crate::crd::Bind9ClusterStatus; + use crate::reconcilers::bind9cluster::status_helpers::cluster_status_changed; + + fn build_current_status(generation: Option) -> Bind9ClusterStatus { + let instances = vec![create_test_instance("instance-0", "default", true)]; + let (instance_count, ready_instances, instance_names, conditions) = + calculate_cluster_status(&instances, "default", "test-cluster"); + Bind9ClusterStatus { + conditions, + observed_generation: generation, + instance_count: Some(instance_count), + ready_instances: Some(ready_instances), + instances: instance_names, + } + } + + #[test] + fn test_cluster_status_changed_no_current_status() { + let instances = vec![create_test_instance("instance-0", "default", true)]; + let (count, ready, names, conditions) = + calculate_cluster_status(&instances, "default", "test-cluster"); + + assert!(cluster_status_changed( + None, + &conditions, + count, + ready, + &names, + Some(1) + )); + } + + #[test] + fn test_cluster_status_changed_generation_only_change_triggers_patch() { + // THE BUG: a spec edit that does not change counts or conditions must + // still trigger the patch so observedGeneration advances - otherwise + // should_reconcile() returns true on every requeue forever. + let current = build_current_status(Some(1)); + let instances = vec![create_test_instance("instance-0", "default", true)]; + let (count, ready, names, conditions) = + calculate_cluster_status(&instances, "default", "test-cluster"); + + assert!( + cluster_status_changed(Some(¤t), &conditions, count, ready, &names, Some(2)), + "generation-only change must trigger a status patch" + ); + } + + #[test] + fn test_cluster_status_changed_unchanged_skips_patch() { + let current = build_current_status(Some(2)); + let instances = vec![create_test_instance("instance-0", "default", true)]; + let (count, ready, names, conditions) = + calculate_cluster_status(&instances, "default", "test-cluster"); + + assert!(!cluster_status_changed( + Some(¤t), + &conditions, + count, + ready, + &names, + Some(2) + )); + } + + #[test] + fn test_cluster_status_changed_condition_change_triggers_patch() { + let current = build_current_status(Some(2)); + let instances = vec![create_test_instance("instance-0", "default", false)]; + let (count, ready, names, conditions) = + calculate_cluster_status(&instances, "default", "test-cluster"); + + assert!(cluster_status_changed( + Some(¤t), + &conditions, + count, + ready, + &names, + Some(2) + )); + } } diff --git a/src/reconcilers/bind9instance/mod.rs b/src/reconcilers/bind9instance/mod.rs index 619a3346..8bc0407f 100644 --- a/src/reconcilers/bind9instance/mod.rs +++ b/src/reconcilers/bind9instance/mod.rs @@ -118,6 +118,36 @@ fn calculate_requeue_duration( Some(std::time::Duration::from_secs(requeue_secs as u64)) } +/// Detects whether the parent cluster's configuration changed since it was last observed. +/// +/// Compares the parent's current `metadata.generation` against the parent +/// generation recorded in the instance's `status.observedParentGeneration`. +/// These are the ONLY two values that may be compared: the instance's own +/// `observed_generation` tracks a different, unrelated counter. +/// +/// # Arguments +/// +/// * `parent_generation` - Current `metadata.generation` of the referenced +/// `Bind9Cluster`/`ClusterBind9Provider` (`None` if no parent exists) +/// * `observed_parent_generation` - Parent generation recorded during the last +/// successful reconciliation (`None` if never recorded) +/// +/// # Returns +/// +/// `true` if the parent exists and its generation differs from the recorded +/// value (or was never recorded), `false` otherwise. +#[must_use] +pub fn parent_generation_changed( + parent_generation: Option, + observed_parent_generation: Option, +) -> bool { + match (parent_generation, observed_parent_generation) { + (Some(parent), Some(observed)) => parent != observed, + (Some(_), None) => true, // Parent exists but was never observed + (None, _) => false, // No parent - nothing to track + } +} + /// Update the `Bind9Instance` status with RNDC key rotation information. /// /// Reads rotation metadata from the RNDC Secret annotations and updates the instance @@ -325,50 +355,33 @@ pub async fn reconcile_bind9instance(ctx: Arc, instance: Bind9Instance) let (cluster, cluster_provider) = fetch_cluster_info(&client, &namespace, &instance).await; // Check if parent cluster configuration has changed since last reconciliation - // This is critical for detecting when RNDC config is added/changed at the cluster level - let parent_config_changed = { - // Check Bind9Cluster generation - let cluster_changed = if let Some(ref c) = cluster { - let cluster_generation = c.metadata.generation.unwrap_or(0); - let instance_observed_gen = observed_generation.unwrap_or(0); - - // If cluster generation is newer than when we last reconciled, parent config may have changed - // Note: This is a heuristic since we don't track parent's observed generation separately - // We compare against instance's observed_generation as a proxy for "last reconciliation time" - if cluster_generation > instance_observed_gen { - debug!( - "Parent Bind9Cluster generation ({}) is newer than instance observed generation ({}), checking for config changes", - cluster_generation, instance_observed_gen - ); - true - } else { - false - } - } else { - false - }; - - // Check ClusterBind9Provider generation - let provider_changed = if let Some(ref cp) = cluster_provider { - let provider_generation = cp.metadata.generation.unwrap_or(0); - let instance_observed_gen = observed_generation.unwrap_or(0); + // This is critical for detecting when RNDC config is added/changed at the cluster level. + // + // The parent's generation is tracked SEPARATELY from the instance's own + // observed_generation via status.observedParentGeneration - the two counters + // are unrelated and must never be compared against each other. + let parent_generation = cluster + .as_ref() + .and_then(|c| c.metadata.generation) + .or_else(|| { + cluster_provider + .as_ref() + .and_then(|cp| cp.metadata.generation) + }); + let observed_parent_generation = instance + .status + .as_ref() + .and_then(|s| s.observed_parent_generation); - // Same heuristic: if provider generation is newer, config may have changed - if provider_generation > instance_observed_gen { - debug!( - "Parent ClusterBind9Provider generation ({}) is newer than instance observed generation ({}), checking for config changes", - provider_generation, instance_observed_gen - ); - true - } else { - false - } - } else { - false - }; + let parent_config_changed = + parent_generation_changed(parent_generation, observed_parent_generation); - cluster_changed || provider_changed - }; + if parent_config_changed { + debug!( + "Parent cluster generation ({:?}) differs from last observed parent generation ({:?})", + parent_generation, observed_parent_generation + ); + } if parent_config_changed { info!( @@ -487,7 +500,15 @@ pub async fn reconcile_bind9instance(ctx: Arc, instance: Bind9Instance) // Update status from current deployment state (only patches if status changed) // Preserve existing cluster_ref from instance status if available let cluster_ref = instance.status.as_ref().and_then(|s| s.cluster_ref.clone()); - update_status_from_deployment(&client, &namespace, &name, &instance, cluster_ref).await?; + update_status_from_deployment( + &client, + &namespace, + &name, + &instance, + cluster_ref, + parent_generation, + ) + .await?; // Reconcile zones after status update reconcile_zones_internal(&client, &ctx.stores, &instance).await?; @@ -545,9 +566,28 @@ pub async fn reconcile_bind9instance(ctx: Arc, instance: Bind9Instance) // Build cluster reference for status let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref()); + // Record the parent generation observed during this successful + // reconciliation. Use the freshly fetched parent (it may have been + // re-fetched by create_or_update_resources). + let observed_parent_generation = cluster + .as_ref() + .and_then(|c| c.metadata.generation) + .or_else(|| { + cluster_provider + .as_ref() + .and_then(|cp| cp.metadata.generation) + }); + // Update status based on actual deployment state - update_status_from_deployment(&client, &namespace, &name, &instance, cluster_ref) - .await?; + update_status_from_deployment( + &client, + &namespace, + &name, + &instance, + cluster_ref, + observed_parent_generation, + ) + .await?; // Update rotation status if Secret is available if let Some(ref secret) = secret { @@ -586,8 +626,17 @@ pub async fn reconcile_bind9instance(ctx: Arc, instance: Bind9Instance) message: Some(format!("Failed to create resources: {e}")), last_transition_time: Some(Utc::now().to_rfc3339()), }; - // No cluster info available on error, pass None - update_status(&client, &instance, vec![error_condition], None).await?; + // No cluster info available on error, pass None for cluster_ref. + // Preserve the previously observed parent generation: the new parent + // config was NOT applied, so it must not be recorded as observed. + update_status( + &client, + &instance, + vec![error_condition], + None, + observed_parent_generation, + ) + .await?; return Err(e); } diff --git a/src/reconcilers/bind9instance/mod_tests.rs b/src/reconcilers/bind9instance/mod_tests.rs index 0623ed03..df9067b0 100644 --- a/src/reconcilers/bind9instance/mod_tests.rs +++ b/src/reconcilers/bind9instance/mod_tests.rs @@ -297,4 +297,46 @@ mod tests { ..Default::default() } } + + // ======================================================================== + // Parent generation tracking (parent_generation_changed) + // ======================================================================== + + use crate::reconcilers::bind9instance::parent_generation_changed; + + #[test] + fn test_parent_generation_changed_detects_newer_parent() { + // Parent generation moved from 3 to 5 since last observed + assert!(parent_generation_changed(Some(5), Some(3))); + } + + #[test] + fn test_parent_generation_changed_unchanged_parent() { + assert!(!parent_generation_changed(Some(3), Some(3))); + } + + #[test] + fn test_parent_generation_changed_never_observed() { + // Parent exists but its generation was never recorded: must reconcile + assert!(parent_generation_changed(Some(1), None)); + } + + #[test] + fn test_parent_generation_changed_no_parent() { + assert!(!parent_generation_changed(None, None)); + assert!(!parent_generation_changed(None, Some(4))); + } + + #[test] + fn test_parent_generation_changed_independent_of_instance_generation() { + // THE BUG (regression guard): the old code compared the PARENT's + // generation against the INSTANCE's own observedGeneration - two + // unrelated counters. A parent at generation 2 whose changes were + // already applied (observed parent generation 2) must NOT re-trigger, + // regardless of what the instance's own generation is; and a parent + // that moved to 7 MUST trigger even if the instance's own observed + // generation is far higher (e.g. 100). + assert!(!parent_generation_changed(Some(2), Some(2))); + assert!(parent_generation_changed(Some(7), Some(2))); + } } diff --git a/src/reconcilers/bind9instance/resources.rs b/src/reconcilers/bind9instance/resources.rs index e197abe9..a9a84069 100644 --- a/src/reconcilers/bind9instance/resources.rs +++ b/src/reconcilers/bind9instance/resources.rs @@ -198,7 +198,7 @@ pub(super) async fn create_or_update_resources( ) .await?; - // 4. Create/update Deployment + // 4. Create/update Deployment (mounts the resolved RNDC Secret) debug!("Step 4: Creating/updating Deployment"); create_or_update_deployment( client, @@ -207,6 +207,7 @@ pub(super) async fn create_or_update_resources( instance, cluster.as_ref(), cluster_provider.as_ref(), + &secret_name, ) .await?; @@ -236,6 +237,93 @@ async fn create_or_update_service_account( create_or_apply(client, namespace, &service_account, "bindy-controller").await } +/// Data keys every operator-managed RNDC `Secret` must contain. +const RNDC_SECRET_REQUIRED_KEYS: [&str; 3] = ["key-name", "algorithm", "secret"]; + +/// Action to take for an existing RNDC `Secret`, decided by +/// [`evaluate_existing_rndc_secret`]. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RndcSecretAction { + /// Secret is valid and up to date — keep it as-is. + Keep, + /// Secret is valid but rotation is enabled and rotation annotations are + /// missing — patch them onto the Secret without regenerating the key. + AddRotationAnnotations, + /// Rotation is due — rotate the key in place. + Rotate, + /// Secret is malformed or has drifted from the desired configuration — + /// delete it and recreate it (the reason explains why). + Recreate(String), +} + +/// Decide what to do with an existing RNDC `Secret`. +/// +/// Pure decision logic extracted from `create_or_update_rndc_secret_with_config` +/// so the ordering is unit-testable. Malformedness is checked FIRST: a Secret +/// with missing data or missing required keys must be recreated, and none of +/// the rotation / drift checks may run against it (running them on a stale +/// in-memory copy after deletion previously caused an early return that left +/// the Secret deleted but never recreated). +/// +/// # Arguments +/// +/// * `secret` - The existing RNDC `Secret` fetched from the API server +/// * `config` - Desired RNDC configuration (resolved via precedence) +/// +/// # Returns +/// +/// The [`RndcSecretAction`] to perform for this Secret. +/// +/// # Errors +/// +/// Returns an error if rotation annotations exist but cannot be parsed. +pub(super) fn evaluate_existing_rndc_secret( + secret: &Secret, + config: &crate::crd::RndcKeyConfig, +) -> Result { + // Malformed Secrets must be recreated before any other check. + let Some(data) = secret.data.as_ref() else { + return Ok(RndcSecretAction::Recreate("Secret has no data".to_string())); + }; + if RNDC_SECRET_REQUIRED_KEYS + .iter() + .any(|key| !data.contains_key(*key)) + { + return Ok(RndcSecretAction::Recreate( + "Secret is missing required keys".to_string(), + )); + } + + // Rotation annotations need to be added before rotation can be evaluated. + let has_annotations = secret + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(crate::constants::ANNOTATION_RNDC_CREATED_AT)) + .is_some(); + if config.auto_rotate && !has_annotations { + return Ok(RndcSecretAction::AddRotationAnnotations); + } + + if config.auto_rotate && should_rotate_secret(secret, config)? { + return Ok(RndcSecretAction::Rotate); + } + + // Configuration drift: the key algorithm changed in the spec. + let current_algorithm = data.get("algorithm").map_or_else( + || "unknown".to_string(), + |v| String::from_utf8_lossy(&v.0).into_owned(), + ); + let desired_algorithm = config.algorithm.as_str(); + if current_algorithm != desired_algorithm { + return Ok(RndcSecretAction::Recreate(format!( + "algorithm mismatch (current: {current_algorithm}, desired: {desired_algorithm})" + ))); + } + + Ok(RndcSecretAction::Keep) +} + /// Create or update the RNDC Secret for BIND9 remote control /// Creates or updates RNDC `Secret` based on configuration. /// @@ -290,44 +378,20 @@ async fn create_or_update_rndc_secret_with_config( let secret_api: Api = Api::namespaced(client.clone(), namespace); - // Check if Secret exists and if rotation is due + // Check the existing Secret (if any) and decide what to do with it. The + // decision logic is a pure function so that the malformed/rotation/drift + // ordering is unit-testable; only `Recreate` falls through to the + // creation path below — every other action returns early. match secret_api.get(&secret_name).await { - Ok(existing_secret) => { - // Verify Secret has required keys first - if let Some(ref data) = existing_secret.data { - if !data.contains_key("key-name") - || !data.contains_key("algorithm") - || !data.contains_key("secret") - { - warn!( - "RNDC Secret {}/{} is missing required keys, will recreate", - namespace, secret_name - ); - secret_api - .delete(&secret_name, &kube::api::DeleteParams::default()) - .await?; - // Fall through to create new Secret - } - } else { - warn!( - "RNDC Secret {}/{} has no data, will recreate", + Ok(existing_secret) => match evaluate_existing_rndc_secret(&existing_secret, config)? { + RndcSecretAction::Keep => { + info!( + "RNDC Secret {}/{} exists and is valid, skipping creation", namespace, secret_name ); - secret_api - .delete(&secret_name, &kube::api::DeleteParams::default()) - .await?; - // Fall through to create new Secret + return Ok(secret_name); } - - // Check if rotation annotations need to be added or updated - let has_annotations = existing_secret - .metadata - .annotations - .as_ref() - .and_then(|a| a.get(crate::constants::ANNOTATION_RNDC_CREATED_AT)) - .is_some(); - - if config.auto_rotate && !has_annotations { + RndcSecretAction::AddRotationAnnotations => { info!( "RNDC Secret {}/{} missing rotation annotations, adding them", namespace, secret_name @@ -335,9 +399,7 @@ async fn create_or_update_rndc_secret_with_config( add_rotation_annotations_to_secret(&secret_api, &secret_name, config).await?; return Ok(secret_name); } - - // Check if rotation is needed - if config.auto_rotate && should_rotate_secret(&existing_secret, config)? { + RndcSecretAction::Rotate => { info!( "RNDC Secret {}/{} rotation is due, rotating", namespace, secret_name @@ -353,32 +415,17 @@ async fn create_or_update_rndc_secret_with_config( .await?; return Ok(secret_name); } - - // Check for configuration drift (algorithm changed) - if let Some(ref data) = existing_secret.data { - if data.contains_key("algorithm") { - let current_algorithm = - std::str::from_utf8(&data.get("algorithm").unwrap().0).unwrap_or("unknown"); - let desired_algorithm = config.algorithm.as_str(); - - if current_algorithm == desired_algorithm { - info!( - "RNDC Secret {}/{} exists and is valid, skipping creation", - namespace, secret_name - ); - return Ok(secret_name); - } - warn!( - "RNDC Secret {}/{} algorithm mismatch (current: {}, desired: {}), will recreate", - namespace, secret_name, current_algorithm, desired_algorithm - ); - secret_api - .delete(&secret_name, &kube::api::DeleteParams::default()) - .await?; - // Fall through to create new Secret - } + RndcSecretAction::Recreate(reason) => { + warn!( + "RNDC Secret {}/{} will be recreated: {}", + namespace, secret_name, reason + ); + secret_api + .delete(&secret_name, &kube::api::DeleteParams::default()) + .await?; + // Fall through to create a new Secret below } - } + }, Err(_) => { info!( "RNDC Secret {}/{} does not exist, creating", @@ -857,32 +904,28 @@ async fn create_or_update_configmap( .and_then(|s| s.allow_transfer.as_ref()), }); - // build_configmap returns None if custom ConfigMaps are referenced; - // it returns Err if any ACL entry fails validation. - if let Some(configmap) = - build_configmap(name, namespace, instance, cluster, role_allow_transfer)? - { - let cm_api: Api = Api::namespaced(client.clone(), namespace); - let cm_name = format!("{name}-config"); - - if (cm_api.get(&cm_name).await).is_ok() { - // ConfigMap exists, update it - info!("Updating ConfigMap {}/{}", namespace, cm_name); - cm_api - .replace(&cm_name, &PostParams::default(), &configmap) - .await?; - } else { - // ConfigMap doesn't exist, create it - info!("Creating ConfigMap {}/{}", namespace, cm_name); - cm_api.create(&PostParams::default(), &configmap).await?; - } - } else { - info!( - "Using custom ConfigMaps for {}/{}, skipping ConfigMap creation", - namespace, name - ); + // build_configmap always returns a ConfigMap (it always carries at least + // rndc.conf, plus any file not overridden by custom configMapRefs); it + // returns Err if any ACL/forwarder/listen entry fails validation. The + // generated ConfigMap must always be created because the Deployment's + // `config` volume references it unconditionally. + let configmap = build_configmap(name, namespace, instance, cluster, role_allow_transfer)?; + let cm_api: Api = Api::namespaced(client.clone(), namespace); + let cm_name = format!("{name}-config"); + + if (cm_api.get(&cm_name).await).is_ok() { + // ConfigMap exists, update it + info!("Updating ConfigMap {}/{}", namespace, cm_name); + cm_api + .replace(&cm_name, &PostParams::default(), &configmap) + .await?; + return Ok(()); } + // ConfigMap doesn't exist, create it + info!("Creating ConfigMap {}/{}", namespace, cm_name); + cm_api.create(&PostParams::default(), &configmap).await?; + Ok(()) } @@ -972,6 +1015,12 @@ fn deployment_needs_update(current: &Deployment, desired: &Deployment) -> bool { } /// Create or update the Deployment for BIND9 +/// +/// `rndc_secret_name` is the resolved RNDC `Secret` name returned by +/// `create_or_update_rndc_secret_with_config` (a `secretRef` / inline secret +/// name, or the auto-generated `{name}-rndc-key` default) and is threaded +/// into the Deployment's rndc-key volume and bindcar env secretKeyRefs. +#[allow(clippy::too_many_arguments)] async fn create_or_update_deployment( client: &Client, namespace: &str, @@ -979,8 +1028,16 @@ async fn create_or_update_deployment( instance: &Bind9Instance, cluster: Option<&Bind9Cluster>, cluster_provider: Option<&crate::crd::ClusterBind9Provider>, + rndc_secret_name: &str, ) -> Result<()> { - let deployment = build_deployment(name, namespace, instance, cluster, cluster_provider); + let deployment = build_deployment( + name, + namespace, + instance, + cluster, + cluster_provider, + rndc_secret_name, + ); let api: Api = Api::namespaced(client.clone(), namespace); // Check if deployment exists - if not, create it and return early diff --git a/src/reconcilers/bind9instance/resources_tests.rs b/src/reconcilers/bind9instance/resources_tests.rs index 53bbadec..d85bf0d7 100644 --- a/src/reconcilers/bind9instance/resources_tests.rs +++ b/src/reconcilers/bind9instance/resources_tests.rs @@ -286,6 +286,141 @@ mod tests { // AND log error message with details } + // ---------------------------------------------------------------- + // evaluate_existing_rndc_secret — pure decision-logic tests + // + // Regression coverage for the malformed-Secret bug: after deleting a + // malformed Secret, control previously continued into the rotation / + // algorithm checks on the stale in-memory copy and could early-return + // WITHOUT recreating the Secret, leaving the Deployment mounting a + // Secret that no longer exists. + // ---------------------------------------------------------------- + + use crate::crd::{RndcAlgorithm, RndcKeyConfig}; + use crate::reconcilers::bind9instance::resources::{ + evaluate_existing_rndc_secret, RndcSecretAction, + }; + use k8s_openapi::api::core::v1::Secret; + use k8s_openapi::ByteString; + use std::collections::BTreeMap; + + fn rndc_config(auto_rotate: bool) -> RndcKeyConfig { + RndcKeyConfig { + auto_rotate, + rotate_after: "720h".to_string(), + secret_ref: None, + secret: None, + algorithm: RndcAlgorithm::HmacSha256, + } + } + + fn valid_secret_data() -> BTreeMap { + let mut data = BTreeMap::new(); + data.insert( + "key-name".to_string(), + ByteString(b"bindy-operator".to_vec()), + ); + data.insert("algorithm".to_string(), ByteString(b"hmac-sha256".to_vec())); + data.insert("secret".to_string(), ByteString(b"c2VjcmV0".to_vec())); + data + } + + fn secret_with_data(data: Option>) -> Secret { + Secret { + metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + name: Some("test-rndc-key".to_string()), + ..Default::default() + }, + data, + ..Default::default() + } + } + + #[test] + fn evaluate_rndc_secret_recreates_when_data_missing() { + let secret = secret_with_data(None); + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(false)).unwrap(); + assert!( + matches!(action, RndcSecretAction::Recreate(_)), + "Secret without data must be recreated, got {action:?}" + ); + } + + #[test] + fn evaluate_rndc_secret_recreates_when_required_keys_missing() { + let mut data = valid_secret_data(); + data.remove("secret"); + let secret = secret_with_data(Some(data)); + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(false)).unwrap(); + assert!( + matches!(action, RndcSecretAction::Recreate(_)), + "Secret missing required keys must be recreated, got {action:?}" + ); + } + + #[test] + fn evaluate_rndc_secret_recreates_malformed_even_with_auto_rotate() { + // The original bug: a malformed Secret with auto_rotate enabled fell + // into the rotation-annotation branch after deletion and returned + // early without recreating. Malformedness must win over rotation. + let secret = secret_with_data(None); + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(true)).unwrap(); + assert!( + matches!(action, RndcSecretAction::Recreate(_)), + "malformed Secret must be recreated regardless of rotation config, got {action:?}" + ); + } + + #[test] + fn evaluate_rndc_secret_keeps_valid_secret() { + let secret = secret_with_data(Some(valid_secret_data())); + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(false)).unwrap(); + assert_eq!(action, RndcSecretAction::Keep); + } + + #[test] + fn evaluate_rndc_secret_recreates_on_algorithm_mismatch() { + let secret = secret_with_data(Some(valid_secret_data())); + let mut config = rndc_config(false); + config.algorithm = RndcAlgorithm::HmacSha512; + let action = evaluate_existing_rndc_secret(&secret, &config).unwrap(); + assert!( + matches!(action, RndcSecretAction::Recreate(_)), + "algorithm drift must recreate the Secret, got {action:?}" + ); + } + + #[test] + fn evaluate_rndc_secret_adds_annotations_when_rotation_enabled() { + let secret = secret_with_data(Some(valid_secret_data())); + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(true)).unwrap(); + assert_eq!(action, RndcSecretAction::AddRotationAnnotations); + } + + #[test] + fn evaluate_rndc_secret_rotates_when_due() { + use chrono::{Duration, Utc}; + + let mut secret = secret_with_data(Some(valid_secret_data())); + let mut annotations = std::collections::BTreeMap::new(); + annotations.insert( + crate::constants::ANNOTATION_RNDC_CREATED_AT.to_string(), + (Utc::now() - Duration::hours(2)).to_rfc3339(), + ); + annotations.insert( + crate::constants::ANNOTATION_RNDC_ROTATE_AT.to_string(), + (Utc::now() - Duration::minutes(5)).to_rfc3339(), + ); + annotations.insert( + crate::constants::ANNOTATION_RNDC_ROTATION_COUNT.to_string(), + "0".to_string(), + ); + secret.metadata.annotations = Some(annotations); + + let action = evaluate_existing_rndc_secret(&secret, &rndc_config(true)).unwrap(); + assert_eq!(action, RndcSecretAction::Rotate); + } + // ---------------------------------------------------------------- // F-001: validate_user_pod_shape — pure-function tests // ---------------------------------------------------------------- diff --git a/src/reconcilers/bind9instance/status_helpers.rs b/src/reconcilers/bind9instance/status_helpers.rs index 63894f72..865c0aef 100644 --- a/src/reconcilers/bind9instance/status_helpers.rs +++ b/src/reconcilers/bind9instance/status_helpers.rs @@ -22,6 +22,8 @@ use crate::reconcilers::pagination::list_all_paginated; /// * `name` - Instance name /// * `instance` - The `Bind9Instance` resource /// * `cluster_ref` - Optional cluster reference to include in status +/// * `observed_parent_generation` - Generation of the referenced parent cluster +/// (`Bind9Cluster`/`ClusterBind9Provider`) observed during this reconciliation /// /// # Errors /// @@ -33,6 +35,7 @@ pub(super) async fn update_status_from_deployment( name: &str, instance: &Bind9Instance, cluster_ref: Option, + observed_parent_generation: Option, ) -> Result<()> { let deploy_api: Api = Api::namespaced(client.clone(), namespace); let pod_api: Api = Api::namespaced(client.clone(), namespace); @@ -136,7 +139,14 @@ pub(super) async fn update_status_from_deployment( all_conditions.extend(pod_conditions); // Update status with all conditions - update_status(client, instance, all_conditions, cluster_ref).await?; + update_status( + client, + instance, + all_conditions, + cluster_ref, + observed_parent_generation, + ) + .await?; } Err(e) => { warn!( @@ -151,13 +161,89 @@ pub(super) async fn update_status_from_deployment( message: Some("Unable to determine deployment status".to_string()), last_transition_time: Some(Utc::now().to_rfc3339()), }; - update_status(client, instance, vec![unknown_condition], cluster_ref).await?; + update_status( + client, + instance, + vec![unknown_condition], + cluster_ref, + observed_parent_generation, + ) + .await?; } } Ok(()) } +/// Determines whether the `Bind9Instance` status patch is needed. +/// +/// Compares the current status against the values about to be written. The +/// patch is needed if any of the following changed: +/// - `cluster_ref` or the zones list +/// - `observed_generation` (so spec edits that don't change conditions still +/// advance `observedGeneration` and stop perpetual re-reconciliation) +/// - `observed_parent_generation` (so parent cluster config changes are +/// recorded once applied) +/// - Any condition's type, status, reason, or message +/// +/// # Arguments +/// +/// * `current` - The status currently stored on the resource (if any) +/// * `conditions` - New conditions about to be written +/// * `cluster_ref` - New cluster reference about to be written +/// * `zones` - Zones list about to be written (preserved from current status) +/// * `generation` - The `metadata.generation` about to be written as `observed_generation` +/// * `observed_parent_generation` - The parent cluster generation about to be written +/// +/// # Returns +/// +/// `true` if the status patch should be applied, `false` if it can be skipped. +#[must_use] +pub fn instance_status_changed( + current: Option<&Bind9InstanceStatus>, + conditions: &[Condition], + cluster_ref: Option<&ClusterReference>, + zones: &[crate::crd::ZoneReference], + generation: Option, + observed_parent_generation: Option, +) -> bool { + let Some(current) = current else { + // No status exists, need to update + return true; + }; + + // Check if cluster_ref or zones changed + if current.cluster_ref.as_ref() != cluster_ref || current.zones != zones { + return true; + } + + // Check if the observed generations are behind the values about to be + // written. Without this, a spec (or parent spec) edit that does not change + // conditions never advances the observed generations, causing perpetual + // re-reconciliation on every requeue. + if current.observed_generation != generation + || current.observed_parent_generation != observed_parent_generation + { + return true; + } + + // Check if any condition changed + if current.conditions.len() != conditions.len() { + return true; + } + + current + .conditions + .iter() + .zip(conditions.iter()) + .any(|(current_cond, new_cond)| { + current_cond.r#type != new_cond.r#type + || current_cond.status != new_cond.status + || current_cond.message != new_cond.message + || current_cond.reason != new_cond.reason + }) +} + /// Update the status of a `Bind9Instance` with multiple conditions. /// /// NOTE: This function does NOT update `status.zones`. Zone reconciliation is handled @@ -171,6 +257,8 @@ pub(super) async fn update_status_from_deployment( /// * `instance` - The instance to update /// * `conditions` - Vector of status conditions to set /// * `cluster_ref` - Optional cluster reference +/// * `observed_parent_generation` - Generation of the referenced parent cluster +/// (`Bind9Cluster`/`ClusterBind9Provider`) observed during this reconciliation /// /// # Errors /// @@ -180,6 +268,7 @@ pub(super) async fn update_status( instance: &Bind9Instance, conditions: Vec, cluster_ref: Option, + observed_parent_generation: Option, ) -> Result<()> { let api: Api = Api::namespaced(client.clone(), &instance.namespace().unwrap_or_default()); @@ -194,33 +283,15 @@ pub(super) async fn update_status( // Compute zones_count from zones length let zones_count = i32::try_from(zones.len()).ok(); - // Check if status has actually changed (now including zones) - let current_status = &instance.status; - let status_changed = - if let Some(current) = current_status { - // Check if cluster_ref or zones changed - if current.cluster_ref != cluster_ref || current.zones != zones { - true - } else { - // Check if any condition changed - if current.conditions.len() == conditions.len() { - // Compare each condition - current.conditions.iter().zip(conditions.iter()).any( - |(current_cond, new_cond)| { - current_cond.r#type != new_cond.r#type - || current_cond.status != new_cond.status - || current_cond.message != new_cond.message - || current_cond.reason != new_cond.reason - }, - ) - } else { - true - } - } - } else { - // No status exists, need to update - true - }; + // Check if status has actually changed (including generation tracking) + let status_changed = instance_status_changed( + instance.status.as_ref(), + &conditions, + cluster_ref.as_ref(), + &zones, + instance.metadata.generation, + observed_parent_generation, + ); // Only update if status has changed if !status_changed { @@ -235,6 +306,7 @@ pub(super) async fn update_status( let new_status = Bind9InstanceStatus { conditions, observed_generation: instance.metadata.generation, + observed_parent_generation, service_address: None, // Will be populated when service is ready cluster_ref, zones, diff --git a/src/reconcilers/bind9instance/status_helpers_tests.rs b/src/reconcilers/bind9instance/status_helpers_tests.rs index f532637d..0fc886d8 100644 --- a/src/reconcilers/bind9instance/status_helpers_tests.rs +++ b/src/reconcilers/bind9instance/status_helpers_tests.rs @@ -93,4 +93,98 @@ mod tests { // Then: Should set status.observedGeneration = 5 // AND include in the status patch } + + // ======================================================================== + // Status patch decision (instance_status_changed) + // ======================================================================== + + use crate::crd::{Bind9InstanceStatus, Condition}; + use crate::reconcilers::bind9instance::status_helpers::instance_status_changed; + + fn ready_condition() -> Condition { + Condition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: Some("AllReady".to_string()), + message: Some("All 1 pods are ready".to_string()), + last_transition_time: Some("2025-01-01T00:00:00Z".to_string()), + } + } + + fn build_current_status( + generation: Option, + parent_generation: Option, + ) -> Bind9InstanceStatus { + Bind9InstanceStatus { + conditions: vec![ready_condition()], + observed_generation: generation, + observed_parent_generation: parent_generation, + ..Default::default() + } + } + + #[test] + fn test_instance_status_changed_no_current_status() { + assert!(instance_status_changed( + None, + &[ready_condition()], + None, + &[], + Some(1), + None + )); + } + + #[test] + fn test_instance_status_changed_generation_only_change_triggers_patch() { + // THE BUG: a spec edit that does not change conditions must still + // trigger the patch so observedGeneration advances - otherwise + // should_reconcile() returns true on every requeue forever. + let current = build_current_status(Some(1), None); + + assert!( + instance_status_changed( + Some(¤t), + &[ready_condition()], + None, + &[], + Some(2), + None + ), + "generation-only change must trigger a status patch" + ); + } + + #[test] + fn test_instance_status_changed_parent_generation_only_change_triggers_patch() { + // A parent cluster config change must be recorded even when the + // instance's own conditions and generation are unchanged + let current = build_current_status(Some(2), Some(5)); + + assert!( + instance_status_changed( + Some(¤t), + &[ready_condition()], + None, + &[], + Some(2), + Some(6) + ), + "parent-generation-only change must trigger a status patch" + ); + } + + #[test] + fn test_instance_status_changed_unchanged_skips_patch() { + let current = build_current_status(Some(2), Some(5)); + + assert!(!instance_status_changed( + Some(¤t), + &[ready_condition()], + None, + &[], + Some(2), + Some(5) + )); + } } diff --git a/src/reconcilers/clusterbind9provider.rs b/src/reconcilers/clusterbind9provider.rs index 92c41cc7..35d03aa8 100644 --- a/src/reconcilers/clusterbind9provider.rs +++ b/src/reconcilers/clusterbind9provider.rs @@ -227,6 +227,75 @@ pub async fn reconcile_clusterbind9provider( Ok(()) } +/// Returns the target namespace for a `ClusterBind9Provider`. +/// +/// Uses `spec.namespace` if set, otherwise falls back to the operator's own +/// namespace (`POD_NAMESPACE` env var), defaulting to `bindy-system`. +fn provider_target_namespace(cluster_provider: &ClusterBind9Provider) -> String { + cluster_provider.spec.namespace.as_ref().map_or_else( + || std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "bindy-system".to_string()), + std::clone::Clone::clone, + ) +} + +/// Computes the namespaces where a managed `Bind9Cluster` is expected to exist. +/// +/// This is the single source of truth shared by `reconcile_namespace_clusters` +/// (which creates the managed clusters) and `detect_cluster_drift` (which +/// verifies them), so the two can never diverge. +/// +/// The expected set is every namespace containing a `Bind9Instance` that +/// references the provider; when no instance references it, the provider's +/// target namespace is used as a fallback. +/// +/// # Arguments +/// +/// * `instances` - All `Bind9Instance` resources in the cluster +/// * `provider_name` - Name of the `ClusterBind9Provider` +/// * `target_namespace` - Fallback namespace when no instances reference the provider +#[must_use] +pub fn expected_cluster_namespaces( + instances: &[Bind9Instance], + provider_name: &str, + target_namespace: &str, +) -> std::collections::HashSet { + let namespaces: std::collections::HashSet = instances + .iter() + .filter(|inst| inst.spec.cluster_ref == provider_name) + .filter_map(kube::ResourceExt::namespace) + .collect(); + + if namespaces.is_empty() { + return std::iter::once(target_namespace.to_string()).collect(); + } + + namespaces +} + +/// Lists instances across all namespaces and computes the expected namespace set. +/// +/// See [`expected_cluster_namespaces`] for the semantics. +/// +/// # Errors +/// +/// Returns an error if listing `Bind9Instance` resources fails. +async fn compute_expected_cluster_namespaces( + client: &Client, + cluster_provider: &ClusterBind9Provider, +) -> Result> { + let cluster_provider_name = cluster_provider.name_any(); + let target_namespace = provider_target_namespace(cluster_provider); + + let instances_api: Api = Api::all(client.clone()); + let all_instances = instances_api.list(&ListParams::default()).await?; + + Ok(expected_cluster_namespaces( + &all_instances.items, + &cluster_provider_name, + &target_namespace, + )) +} + /// Reconciles namespace-scoped `Bind9Cluster` resources for this global cluster. /// /// This function creates or updates a namespace-scoped `Bind9Cluster` resource in each @@ -250,42 +319,20 @@ async fn reconcile_namespace_clusters( use crate::labels::{ BINDY_CLUSTER_LABEL, BINDY_MANAGED_BY_LABEL, MANAGED_BY_CLUSTER_BIND9_PROVIDER, }; - use kube::api::{ListParams, PostParams}; - use std::collections::{BTreeMap, HashSet}; + use kube::api::PostParams; + use std::collections::BTreeMap; let cluster_provider_name = cluster_provider.name_any(); - // Get target namespace from spec or default to operator's namespace - let target_namespace = cluster_provider.spec.namespace.as_ref().map_or_else( - || std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "bindy-system".to_string()), - std::clone::Clone::clone, - ); - debug!( - "Reconciling namespace-scoped Bind9Cluster for global cluster {} in namespace {}", - cluster_provider_name, target_namespace + "Reconciling namespace-scoped Bind9Cluster resources for global cluster {}", + cluster_provider_name ); - // List all instances across all namespaces that reference this global cluster - let instances_api: Api = Api::all(client.clone()); - let all_instances = instances_api.list(&ListParams::default()).await?; - - // Find unique namespaces that have instances referencing this global cluster - let namespaces: HashSet = all_instances - .items - .iter() - .filter(|inst| inst.spec.cluster_ref == cluster_provider_name) - .filter_map(kube::ResourceExt::namespace) - .collect(); - - // If no instances reference this global cluster, still create cluster in target namespace - let namespaces_to_reconcile: HashSet = if namespaces.is_empty() { - let mut set = HashSet::new(); - set.insert(target_namespace); - set - } else { - namespaces - }; + // Compute the namespaces needing a managed Bind9Cluster using the shared + // helper (also used by detect_cluster_drift so the two cannot diverge) + let namespaces_to_reconcile = + compute_expected_cluster_namespaces(client, cluster_provider).await?; debug!( "Found {} namespace(s) needing Bind9Cluster for global cluster {}", @@ -438,31 +485,7 @@ async fn update_cluster_status(client: &Client, cluster: &ClusterBind9Provider) let new_status = calculate_cluster_status(&instances, cluster.metadata.generation); // Check if status has actually changed before patching - let status_changed = if let Some(current_status) = &cluster.status { - // Check if instance count or ready count changed - if current_status.instance_count != new_status.instance_count - || current_status.ready_instances != new_status.ready_instances - { - true - } else if let Some(current_condition) = current_status.conditions.first() { - // Check if condition changed - let new_condition = new_status.conditions.first(); - match new_condition { - Some(new_cond) => { - current_condition.r#type != new_cond.r#type - || current_condition.status != new_cond.status - || current_condition.message != new_cond.message - } - None => true, // New status has no condition, definitely changed - } - } else { - // Current status has no condition but new status might - !new_status.conditions.is_empty() - } - } else { - // No current status, definitely need to update - true - }; + let status_changed = cluster_status_needs_update(cluster.status.as_ref(), &new_status); // Only update if status has changed if !status_changed { @@ -486,6 +509,64 @@ async fn update_cluster_status(client: &Client, cluster: &ClusterBind9Provider) Ok(()) } +/// Determines whether the `ClusterBind9Provider` status patch is needed. +/// +/// Compares the current status against the newly calculated status. The patch +/// is needed if any of the following changed: +/// - `instance_count` or `ready_instances` +/// - `observed_generation` (so spec edits that don't change counts/conditions +/// still advance `observedGeneration` and stop perpetual re-reconciliation) +/// - The first (encompassing) condition's type, status, or message +/// +/// # Arguments +/// +/// * `current_status` - The status currently stored on the resource (if any) +/// * `new_status` - The freshly calculated status about to be written +/// +/// # Returns +/// +/// `true` if the status patch should be applied, `false` if it can be skipped. +#[must_use] +pub fn cluster_status_needs_update( + current_status: Option<&Bind9ClusterStatus>, + new_status: &Bind9ClusterStatus, +) -> bool { + let Some(current_status) = current_status else { + // No current status, definitely need to update + return true; + }; + + // Check if instance count or ready count changed + if current_status.instance_count != new_status.instance_count + || current_status.ready_instances != new_status.ready_instances + { + return true; + } + + // Check if the observed generation is behind the generation about to be + // written. Without this, a spec edit that does not change counts or + // conditions never advances observedGeneration, causing should_reconcile() + // to return true on every requeue forever. + if current_status.observed_generation != new_status.observed_generation { + return true; + } + + // Check if the encompassing condition changed + let Some(current_condition) = current_status.conditions.first() else { + // Current status has no condition but new status might + return !new_status.conditions.is_empty(); + }; + + match new_status.conditions.first() { + Some(new_cond) => { + current_condition.r#type != new_cond.r#type + || current_condition.status != new_cond.status + || current_condition.message != new_cond.message + } + None => true, // New status has no condition, definitely changed + } +} + /// Calculates the cluster status based on instance states. /// /// # Arguments @@ -572,8 +653,10 @@ pub fn calculate_cluster_status( /// Detects drift in managed `Bind9Cluster` resources. /// -/// Compares the expected number of namespace-scoped `Bind9Cluster` resources -/// (should be 1 per target namespace) with the actual count of managed clusters. +/// Checks every namespace where `reconcile_namespace_clusters` is expected to +/// have created a managed `Bind9Cluster` (computed via the shared +/// [`expected_cluster_namespaces`] helper) and verifies that exactly one +/// managed cluster exists there with a spec matching the provider's spec. /// /// # Arguments /// @@ -582,13 +665,14 @@ pub fn calculate_cluster_status( /// /// # Returns /// -/// * `Ok(true)` - If drift is detected (clusters missing or extra clusters exist) +/// * `Ok(true)` - If drift is detected (a managed cluster is missing in an +/// expected namespace, extra managed clusters exist there, or a spec differs) /// * `Ok(false)` - If no drift detected /// * `Err(_)` - If API calls fail /// /// # Errors /// -/// Returns an error if listing `Bind9Cluster` resources fails. +/// Returns an error if listing `Bind9Instance` or `Bind9Cluster` resources fails. async fn detect_cluster_drift( client: &Client, cluster_provider: &ClusterBind9Provider, @@ -601,57 +685,55 @@ async fn detect_cluster_drift( let cluster_provider_name = cluster_provider.name_any(); - // Get target namespace from spec or default - let target_namespace = cluster_provider.spec.namespace.as_ref().map_or_else( - || std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "bindy-system".to_string()), - std::clone::Clone::clone, - ); + // Use the SAME namespace set that reconcile_namespace_clusters creates + // managed clusters in. Checking only the target namespace would report + // perpetual false drift when instances live in other namespaces, and would + // never detect a deleted managed cluster outside the target namespace. + let expected_namespaces = compute_expected_cluster_namespaces(client, cluster_provider).await?; - // List all Bind9Cluster resources in the target namespace - let clusters_api: Api = Api::namespaced(client.clone(), &target_namespace); - let clusters = clusters_api.list(&ListParams::default()).await?; + // We expect exactly 1 managed cluster in each expected namespace + let expected_count_per_namespace = 1; - // Filter for managed clusters - let managed_clusters: Vec<_> = clusters - .items - .into_iter() - .filter(|cluster| { - cluster.metadata.labels.as_ref().is_some_and(|labels| { - labels.get(BINDY_MANAGED_BY_LABEL) - == Some(&MANAGED_BY_CLUSTER_BIND9_PROVIDER.to_string()) - && labels.get(BINDY_CLUSTER_LABEL) == Some(&cluster_provider_name.clone()) - }) - }) - .collect(); - - // We expect exactly 1 managed cluster in the target namespace - let expected_count = 1; - let actual_count = managed_clusters.len(); + for namespace in &expected_namespaces { + // List all Bind9Cluster resources in this namespace + let clusters_api: Api = Api::namespaced(client.clone(), namespace); + let clusters = clusters_api.list(&ListParams::default()).await?; - // Check count drift - if actual_count != expected_count { - info!( - "Cluster count drift detected for ClusterBind9Provider {}: expected {} Bind9Cluster in namespace {}, found {}", - cluster_provider_name, expected_count, target_namespace, actual_count - ); - return Ok(true); - } - - // Check spec drift - compare managed cluster's spec with desired spec - if let Some(managed_cluster) = managed_clusters.first() { - // The desired spec is just the common spec from the cluster provider - let desired_common_spec = &cluster_provider.spec.common; - let actual_common_spec = &managed_cluster.spec.common; + // Filter for managed clusters + let managed_clusters: Vec<_> = clusters + .items + .into_iter() + .filter(|cluster| { + cluster.metadata.labels.as_ref().is_some_and(|labels| { + labels.get(BINDY_MANAGED_BY_LABEL) + == Some(&MANAGED_BY_CLUSTER_BIND9_PROVIDER.to_string()) + && labels.get(BINDY_CLUSTER_LABEL) == Some(&cluster_provider_name.clone()) + }) + }) + .collect(); - // Compare specs - if they differ, drift is detected - if desired_common_spec != actual_common_spec { + // Check count drift in this namespace + let actual_count = managed_clusters.len(); + if actual_count != expected_count_per_namespace { info!( - "Cluster spec drift detected for ClusterBind9Provider {} in namespace {}: \ - managed Bind9Cluster spec differs from desired spec", - cluster_provider_name, target_namespace + "Cluster count drift detected for ClusterBind9Provider {}: expected {} Bind9Cluster in namespace {}, found {}", + cluster_provider_name, expected_count_per_namespace, namespace, actual_count ); return Ok(true); } + + // Check spec drift - compare managed cluster's spec with desired spec + if let Some(managed_cluster) = managed_clusters.first() { + // The desired spec is just the common spec from the cluster provider + if cluster_provider.spec.common != managed_cluster.spec.common { + info!( + "Cluster spec drift detected for ClusterBind9Provider {} in namespace {}: \ + managed Bind9Cluster spec differs from desired spec", + cluster_provider_name, namespace + ); + return Ok(true); + } + } } // No drift detected diff --git a/src/reconcilers/clusterbind9provider_tests.rs b/src/reconcilers/clusterbind9provider_tests.rs index 5e915c7f..32eee546 100644 --- a/src/reconcilers/clusterbind9provider_tests.rs +++ b/src/reconcilers/clusterbind9provider_tests.rs @@ -116,6 +116,7 @@ mod tests { last_transition_time: None, }], observed_generation: Some(1), + observed_parent_generation: None, service_address: Some("127.0.0.1".to_string()), cluster_ref: None, zones: Vec::new(), @@ -132,6 +133,7 @@ mod tests { last_transition_time: None, }], observed_generation: Some(1), + observed_parent_generation: None, service_address: None, cluster_ref: None, zones: Vec::new(), @@ -166,4 +168,96 @@ mod tests { status, } } + + // ======================================================================== + // Status patch decision (cluster_status_needs_update) + // ======================================================================== + + use super::super::clusterbind9provider::{ + cluster_status_needs_update, expected_cluster_namespaces, + }; + + #[test] + fn test_cluster_status_needs_update_no_current_status() { + let instances = vec![create_test_instance("primary-0", "test-ns", true)]; + let new_status = calculate_cluster_status(&instances, Some(1)); + + assert!(cluster_status_needs_update(None, &new_status)); + } + + #[test] + fn test_cluster_status_needs_update_generation_only_change_triggers_patch() { + // THE BUG: a spec edit that does not change counts or conditions bumps + // metadata.generation only. The patch must still be applied so + // observedGeneration advances - otherwise should_reconcile() returns + // true on every requeue forever. + let instances = vec![create_test_instance("primary-0", "test-ns", true)]; + let current = calculate_cluster_status(&instances, Some(1)); + let new_status = calculate_cluster_status(&instances, Some(2)); + + assert!( + cluster_status_needs_update(Some(¤t), &new_status), + "generation-only change must trigger a status patch" + ); + } + + #[test] + fn test_cluster_status_needs_update_unchanged_skips_patch() { + let instances = vec![create_test_instance("primary-0", "test-ns", true)]; + let current = calculate_cluster_status(&instances, Some(2)); + let new_status = calculate_cluster_status(&instances, Some(2)); + + assert!(!cluster_status_needs_update(Some(¤t), &new_status)); + } + + #[test] + fn test_cluster_status_needs_update_count_change_triggers_patch() { + let one = vec![create_test_instance("primary-0", "test-ns", true)]; + let two = vec![ + create_test_instance("primary-0", "test-ns", true), + create_test_instance("primary-1", "test-ns", true), + ]; + let current = calculate_cluster_status(&one, Some(2)); + let new_status = calculate_cluster_status(&two, Some(2)); + + assert!(cluster_status_needs_update(Some(¤t), &new_status)); + } + + // ======================================================================== + // Expected namespace set shared by reconcile + drift detection + // ======================================================================== + + #[test] + fn test_expected_cluster_namespaces_falls_back_to_target_namespace() { + // No instance references the provider: the managed cluster is expected + // in the target namespace only + let instances = vec![]; + let namespaces = expected_cluster_namespaces(&instances, "my-provider", "bindy-system"); + + assert_eq!(namespaces.len(), 1); + assert!(namespaces.contains("bindy-system")); + } + + #[test] + fn test_expected_cluster_namespaces_uses_instance_namespaces() { + // THE BUG: drift detection previously only inspected the target + // namespace. Instances in ns1/ns2 mean managed clusters are created in + // ns1/ns2 - the expected set must be exactly those namespaces. + let mut inst_a = create_test_instance("primary-0", "ns1", true); + inst_a.spec.cluster_ref = "my-provider".to_string(); + let mut inst_b = create_test_instance("secondary-0", "ns2", true); + inst_b.spec.cluster_ref = "my-provider".to_string(); + // An instance referencing a DIFFERENT provider must not contribute + let mut other = create_test_instance("other-0", "ns3", true); + other.spec.cluster_ref = "other-provider".to_string(); + + let instances = vec![inst_a, inst_b, other]; + let namespaces = expected_cluster_namespaces(&instances, "my-provider", "bindy-system"); + + assert_eq!(namespaces.len(), 2); + assert!(namespaces.contains("ns1")); + assert!(namespaces.contains("ns2")); + assert!(!namespaces.contains("ns3")); + assert!(!namespaces.contains("bindy-system")); + } } diff --git a/src/reconcilers/dnszone.rs b/src/reconcilers/dnszone.rs index 5b09fe64..23d3b70f 100644 --- a/src/reconcilers/dnszone.rs +++ b/src/reconcilers/dnszone.rs @@ -656,7 +656,7 @@ pub async fn reconcile_dnszone( // - New instances added to the cluster get zones automatically // - Drift detection: if someone manually deletes a zone, it's recreated // - True Kubernetes declarative reconciliation: actual state continuously matches desired state - let (primary_count, secondary_count) = bind9_config::configure_zone_on_instances( + let (primary_outcome, secondary_outcome) = bind9_config::configure_zone_on_instances( ctx.clone(), &dnszone, zone_manager, @@ -668,7 +668,8 @@ pub async fn reconcile_dnszone( // Discover DNS records and update status let (record_refs, records_count) = - discovery::discover_and_update_records(&client, &dnszone, &mut status_updater).await?; + discovery::discover_and_update_records(&client, &dnszone, &mut status_updater, &ctx.stores) + .await?; // Check if all discovered records are ready and trigger zone transfers if needed if records_count > 0 { @@ -702,8 +703,8 @@ pub async fn reconcile_dnszone( &spec.zone_name, &namespace, &name, - primary_count, - secondary_count, + primary_outcome, + secondary_outcome, expected_primary_count, expected_secondary_count, records_count, @@ -738,8 +739,11 @@ pub async fn reconcile_dnszone( /// /// # Returns /// -/// * `Ok(usize)` - Number of primary endpoints successfully configured -/// * `Err(_)` - If zone addition failed +/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts. +/// An instance counts as configured only if ALL of its ready endpoints accepted +/// the zone, so the instance count is directly comparable with the expected +/// PRIMARY instance count when computing readiness. +/// * `Err(_)` - If zone addition failed on every endpoint /// /// # Errors /// @@ -755,7 +759,7 @@ pub async fn add_dnszone( zone_manager: &crate::bind9::Bind9Manager, status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, instance_refs: &[crate::crd::InstanceReference], -) -> Result { +) -> Result { let client = ctx.client.clone(); let namespace = dnszone.namespace().unwrap_or_default(); let name = dnszone.name_any(); @@ -936,8 +940,11 @@ pub async fn add_dnszone( let errors = Arc::new(Mutex::new(Vec::::new())); let status_updater_shared = Arc::new(Mutex::new(status_updater)); - // Create a stream of futures for all instances - let _instance_results = stream::iter(primary_instance_refs.iter()) + // Create a stream of futures for all instances. + // Each instance future resolves to `true` only if EVERY endpoint of that + // instance accepted the zone (added or already present) - this is the + // per-INSTANCE success signal used for readiness computation. + let instance_results = stream::iter(primary_instance_refs.iter()) .then(|instance_ref| { let client = client.clone(); let zone_manager = zone_manager.clone(); @@ -966,7 +973,7 @@ pub async fn add_dnszone( Err(e) => { let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name); errors.lock().await.push(err_msg); - return; + return false; } }; @@ -976,7 +983,7 @@ pub async fn add_dnszone( Err(e) => { let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name); errors.lock().await.push(err_msg); - return; + return false; } }; @@ -1104,23 +1111,20 @@ pub async fn add_dnszone( "Marked primary instance {}/{} as configured for zone {}", instance_ref.namespace, instance_ref.name, zone_name ); - - // PHASE 2 COMPLETION: Update Bind9Instance.status.selectedZones[].lastReconciledAt - // This signals successful zone configuration and prevents infinite reconciliation loops - // STUB: No longer needed - function is a no-op - // update_zone_reconciled_timestamp( - // &client, - // &instance_ref.name, - // &instance_ref.namespace, - // &zone_name_ref, - // &zone_namespace, - // ); } + + // The instance counts as fully configured only if every one of + // its ready endpoints accepted the zone (added OR already + // present). A single failed endpoint means the instance is NOT + // fully serving the zone and must not count towards readiness. + !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok) } }) - .collect::>() + .collect::>() .await; + let instances_configured = instance_results.iter().filter(|ok| **ok).count(); + let first_endpoint = Arc::try_unwrap(first_endpoint) .expect("Failed to unwrap first_endpoint Arc") .into_inner(); @@ -1144,9 +1148,10 @@ pub async fn add_dnszone( } info!( - "Successfully added zone {} to {} endpoint(s) across {} primary instance(s)", + "Successfully added zone {} to {} endpoint(s) across {}/{} fully configured primary instance(s)", spec.zone_name, total_endpoints, + instances_configured, primary_instance_refs.len() ); @@ -1205,7 +1210,10 @@ pub async fn add_dnszone( ); } - Ok(total_endpoints) + Ok(types::ZoneConfigOutcome { + instances_configured, + endpoints_configured: total_endpoints, + }) } /// Adds a DNS zone to all secondary instances in the cluster with primaries configured. @@ -1223,8 +1231,11 @@ pub async fn add_dnszone( /// /// # Returns /// -/// * `Ok(usize)` - Number of secondary endpoints successfully configured -/// * `Err(_)` - If zone addition failed +/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts. +/// An instance counts as configured only if ALL of its ready endpoints accepted +/// the zone, so the instance count is directly comparable with the expected +/// SECONDARY instance count when computing readiness. +/// * `Err(_)` - If zone addition failed on every endpoint /// /// # Errors /// @@ -1241,7 +1252,7 @@ pub async fn add_dnszone_to_secondaries( primary_ips: &[String], status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, instance_refs: &[crate::crd::InstanceReference], -) -> Result { +) -> Result { let client = ctx.client.clone(); let namespace = dnszone.namespace().unwrap_or_default(); let name = dnszone.name_any(); @@ -1252,7 +1263,7 @@ pub async fn add_dnszone_to_secondaries( "No primary IPs provided for secondary zone {}/{} - skipping secondary configuration", namespace, spec.zone_name ); - return Ok(0); + return Ok(types::ZoneConfigOutcome::default()); } info!( @@ -1272,7 +1283,7 @@ pub async fn add_dnszone_to_secondaries( "No secondary instances found for DNSZone {}/{} - skipping secondary zone configuration", namespace, name ); - return Ok(0); + return Ok(types::ZoneConfigOutcome::default()); } info!( @@ -1288,8 +1299,11 @@ pub async fn add_dnszone_to_secondaries( let errors = Arc::new(Mutex::new(Vec::::new())); let status_updater_shared = Arc::new(Mutex::new(status_updater)); - // Create a stream of futures for all secondary instances - let _instance_results = stream::iter(secondary_instance_refs.iter()) + // Create a stream of futures for all secondary instances. + // Each instance future resolves to `true` only if EVERY endpoint of that + // instance accepted the zone (added or already present) - this is the + // per-INSTANCE success signal used for readiness computation. + let instance_results = stream::iter(secondary_instance_refs.iter()) .then(|instance_ref| { let client = client.clone(); let zone_manager = zone_manager.clone(); @@ -1315,7 +1329,7 @@ pub async fn add_dnszone_to_secondaries( Err(e) => { let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name); errors.lock().await.push(err_msg); - return; + return false; } }; @@ -1325,7 +1339,7 @@ pub async fn add_dnszone_to_secondaries( Err(e) => { let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name); errors.lock().await.push(err_msg); - return; + return false; } }; @@ -1480,25 +1494,26 @@ pub async fn add_dnszone_to_secondaries( "Marked secondary instance {}/{} as configured for zone {}", instance_ref.namespace, instance_ref.name, zone_name ); - - // PHASE 2 COMPLETION: Update Bind9Instance.status.selectedZones[].lastReconciledAt - // This signals successful zone configuration and prevents infinite reconciliation loops - // STUB: No longer needed - function is a no-op - // update_zone_reconciled_timestamp( - // &client, - // &instance_ref.name, - // &instance_ref.namespace, - // &zone_name_ref, - // &zone_namespace, - // ); } + + // The instance counts as fully configured only if every one of + // its ready endpoints accepted the zone (added OR already + // present). A single failed endpoint means the instance is NOT + // fully serving the zone and must not count towards readiness. + !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok) } }) - .collect::>() + .collect::>() .await; - let total_endpoints = Arc::try_unwrap(total_endpoints).unwrap().into_inner(); - let errors = Arc::try_unwrap(errors).unwrap().into_inner(); + let instances_configured = instance_results.iter().filter(|ok| **ok).count(); + + let total_endpoints = Arc::try_unwrap(total_endpoints) + .expect("Failed to unwrap total_endpoints Arc") + .into_inner(); + let errors = Arc::try_unwrap(errors) + .expect("Failed to unwrap errors Arc") + .into_inner(); // If ALL operations failed, return an error if total_endpoints == 0 && !errors.is_empty() { @@ -1510,13 +1525,17 @@ pub async fn add_dnszone_to_secondaries( } info!( - "Successfully configured secondary zone {} on {} endpoint(s) across {} secondary instance(s)", + "Successfully configured secondary zone {} on {} endpoint(s) across {}/{} fully configured secondary instance(s)", spec.zone_name, total_endpoints, + instances_configured, secondary_instance_refs.len() ); - Ok(total_endpoints) + Ok(types::ZoneConfigOutcome { + instances_configured, + endpoints_configured: total_endpoints, + }) } /// Deletes a DNS zone and its associated zone files. @@ -1566,13 +1585,18 @@ pub async fn delete_dnszone( let secondary_instance_refs = secondary::filter_secondary_instances(&client, &instance_refs).await?; - // Delete from all primary instances + // Delete from all primary instances. + // Deletion cleanup uses SkipUnavailable: an instance with zero ready + // endpoints must not block finalizer removal forever - its DNS data is + // unreachable (and lost anyway with ephemeral storage). Real API errors + // still propagate so the next reconcile retries. if !primary_instance_refs.is_empty() { - let (_first_endpoint, total_endpoints) = helpers::for_each_instance_endpoint( + let (_first_endpoint, total_endpoints) = helpers::for_each_instance_endpoint_with_policy( &client, &primary_instance_refs, false, // with_rndc_key = false for zone deletion "http", // Use HTTP API port for zone deletion via bindcar API + helpers::EndpointFailurePolicy::SkipUnavailable, |pod_endpoint, instance_name, _rndc_key| { let zone_name = spec.zone_name.clone(); let zone_manager = zone_manager.clone(); @@ -1616,9 +1640,28 @@ pub async fn delete_dnszone( let mut secondary_endpoints_deleted = 0; for instance_ref in &secondary_instance_refs { - let endpoints = - helpers::get_endpoint(&client, &instance_ref.namespace, &instance_ref.name, "http") - .await?; + // Deletion cleanup: skip secondary instances with no reachable + // endpoints instead of blocking finalizer removal forever. Real + // (potentially transient) API errors still propagate for retry. + let endpoints = match helpers::get_endpoint( + &client, + &instance_ref.namespace, + &instance_ref.name, + "http", + ) + .await + { + Ok(eps) => eps, + Err(e) if helpers::is_unavailable_for_deletion(&e) => { + warn!( + "SKIPPING secondary instance {}/{} during zone deletion: no reachable endpoints ({e:#}). \ + Zone data on this instance cannot be cleaned up and may be orphaned.", + instance_ref.namespace, instance_ref.name + ); + continue; + } + Err(e) => return Err(e), + }; for endpoint in &endpoints { let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port); diff --git a/src/reconcilers/dnszone/bind9_config.rs b/src/reconcilers/dnszone/bind9_config.rs index 2f13ab7e..41c152c4 100644 --- a/src/reconcilers/dnszone/bind9_config.rs +++ b/src/reconcilers/dnszone/bind9_config.rs @@ -33,7 +33,9 @@ use crate::crd::{DNSZone, InstanceReference}; /// /// # Returns /// -/// Tuple of `(primary_count, secondary_count)` - number of successfully configured instances +/// Tuple of `(primary_outcome, secondary_outcome)` - per-instance and per-endpoint +/// configuration counts for primary and secondary instances (see +/// [`super::types::ZoneConfigOutcome`]) /// /// # Errors /// @@ -42,7 +44,9 @@ use crate::crd::{DNSZone, InstanceReference}; /// - Primary configuration fails completely /// - Kubernetes API operations fail /// -/// Note: Secondary configuration failure is non-fatal and logged as a warning +/// Note: Secondary configuration failure is non-fatal and logged as a warning. +/// On every fatal error path the Ready condition is set to False and the +/// Progressing condition is resolved before the error is returned. #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] pub async fn configure_zone_on_instances( @@ -52,7 +56,10 @@ pub async fn configure_zone_on_instances( status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, instance_refs: &[InstanceReference], _unreconciled_instances: &[InstanceReference], -) -> Result<(usize, usize)> { +) -> Result<( + super::types::ZoneConfigOutcome, + super::types::ZoneConfigOutcome, +)> { let client = ctx.client.clone(); let namespace = dnszone.namespace().unwrap_or_default(); let spec = &dnszone.spec; @@ -82,12 +89,8 @@ pub async fn configure_zone_on_instances( ips } Ok(_) => { - status_updater.set_condition( - "Degraded", - "True", - "PrimaryFailed", - "No primary servers found - cannot configure secondary zones", - ); + let message = "No primary servers found - cannot configure secondary zones"; + set_failure_conditions(status_updater, "PrimaryFailed", message); // Apply status before returning error status_updater.apply(&client).await?; return Err(anyhow!( @@ -97,9 +100,8 @@ pub async fn configure_zone_on_instances( )); } Err(e) => { - status_updater.set_condition( - "Degraded", - "True", + set_failure_conditions( + status_updater, "PrimaryFailed", &format!("Failed to find primary servers: {e}"), ); @@ -113,7 +115,7 @@ pub async fn configure_zone_on_instances( // Primary instances are marked as reconciled inside add_dnszone() immediately after success // CRITICAL: We pass ALL instances (not just unreconciled ones) to ensure zones are recreated // after pod restarts. The add_zones() function is idempotent (checks zone_exists first). - let primary_count = match super::add_dnszone( + let primary_outcome = match super::add_dnszone( ctx.clone(), dnszone.clone(), zone_manager, @@ -122,23 +124,22 @@ pub async fn configure_zone_on_instances( ) .await { - Ok(count) => { + Ok(outcome) => { // Update status after successful primary reconciliation (in-memory) status_updater.set_condition( "Progressing", "True", "PrimaryReconciled", &format!( - "Zone {} configured on {} primary server(s)", - spec.zone_name, count + "Zone {} configured on {} primary instance(s) ({} endpoint(s))", + spec.zone_name, outcome.instances_configured, outcome.endpoints_configured ), ); - count + outcome } Err(e) => { - status_updater.set_condition( - "Degraded", - "True", + set_failure_conditions( + status_updater, "PrimaryFailed", &format!("Failed to configure zone on primary servers: {e}"), ); @@ -160,7 +161,7 @@ pub async fn configure_zone_on_instances( // Secondary instances are marked as reconciled inside add_dnszone_to_secondaries() immediately after success // CRITICAL: We pass ALL instances (not just unreconciled ones) to ensure zones are recreated // after pod restarts. The add_zones() function is idempotent (checks zone_exists first). - let secondary_count = match super::add_dnszone_to_secondaries( + let secondary_outcome = match super::add_dnszone_to_secondaries( ctx.clone(), dnszone.clone(), zone_manager, @@ -170,20 +171,20 @@ pub async fn configure_zone_on_instances( ) .await { - Ok(count) => { + Ok(outcome) => { // Update status after successful secondary reconciliation (in-memory) - if count > 0 { + if outcome.endpoints_configured > 0 { status_updater.set_condition( "Progressing", "True", "SecondaryReconciled", &format!( - "Zone {} configured on {} secondary server(s)", - spec.zone_name, count + "Zone {} configured on {} secondary instance(s) ({} endpoint(s))", + spec.zone_name, outcome.instances_configured, outcome.endpoints_configured ), ); } - count + outcome } Err(e) => { // Secondary failure is non-fatal - primaries still work @@ -196,14 +197,43 @@ pub async fn configure_zone_on_instances( "True", "SecondaryFailed", &format!( - "Zone configured on {primary_count} primary server(s) but secondary configuration failed: {e}" + "Zone configured on {} primary instance(s) but secondary configuration failed: {e}", + primary_outcome.instances_configured ), ); - 0 + super::types::ZoneConfigOutcome::default() } }; - Ok((primary_count, secondary_count)) + Ok((primary_outcome, secondary_outcome)) +} + +/// Sets the condition triple for a fatal zone configuration failure (in-memory). +/// +/// Ensures the conditions converge to a consistent failed state: +/// - `Degraded=True` with the failure reason and message +/// - `Ready=False` with the same reason and message (a previously set +/// `Ready=True` must never survive a failed reconciliation) +/// - `Progressing=False` (the reconciliation attempt has finished) +/// +/// # Arguments +/// +/// * `status_updater` - Status updater collecting in-memory condition changes +/// * `reason` - Programmatic failure reason in `CamelCase` (e.g. `PrimaryFailed`) +/// * `message` - Human-readable failure explanation +pub fn set_failure_conditions( + status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, + reason: &str, + message: &str, +) { + status_updater.set_condition("Degraded", "True", reason, message); + status_updater.set_condition("Ready", "False", reason, message); + status_updater.set_condition( + "Progressing", + "False", + reason, + "Reconciliation attempt finished with errors", + ); } #[cfg(test)] diff --git a/src/reconcilers/dnszone/cleanup.rs b/src/reconcilers/dnszone/cleanup.rs index cb45057b..429d4723 100644 --- a/src/reconcilers/dnszone/cleanup.rs +++ b/src/reconcilers/dnszone/cleanup.rs @@ -6,11 +6,56 @@ //! This module handles cleanup of deleted instances and stale records from zone status. use anyhow::Result; -use kube::Client; +use kube::{Api, Client}; use tracing::{debug, info, warn}; +use super::helpers::HTTP_STATUS_NOT_FOUND; use crate::crd::DNSZone; +/// Converts a Kubernetes `get` result into an existence check. +/// +/// CRITICAL: Only a 404 (`NotFound`) response means the resource is deleted. +/// Any other error (timeout, 429, 5xx, auth failure, ...) is potentially +/// transient and MUST be propagated - treating it as "deleted" would trigger +/// the self-healing cleanup path and delete live DNS data for a resource that +/// still exists. +/// +/// # Arguments +/// +/// * `result` - The result of an `Api::get` call +/// +/// # Returns +/// +/// * `Ok(true)` - The resource exists +/// * `Ok(false)` - The API returned 404: the resource is deleted +/// +/// # Errors +/// +/// Returns the original error for any non-404 failure so the caller aborts +/// this cleanup pass and retries on the next reconciliation. +pub(super) fn existence_from_get_result( + result: std::result::Result, +) -> Result { + match result { + Ok(_) => Ok(true), + Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => Ok(false), + Err(e) => Err(e.into()), + } +} + +/// Checks whether a namespaced resource exists, distinguishing 404 from +/// transient API errors (see [`existence_from_get_result`]). +/// +/// # Errors +/// +/// Returns an error for any non-404 API failure. +async fn resource_exists(api: &Api, name: &str) -> Result +where + K: kube::Resource + Clone + std::fmt::Debug + serde::de::DeserializeOwned, +{ + existence_from_get_result(api.get(name).await) +} + /// Clean up deleted instances from zone status. /// /// Iterates through instances in zone status and removes any that no longer exist @@ -64,12 +109,14 @@ pub async fn cleanup_deleted_instances( let mut deleted_count = 0; - // Check each instance to see if it still exists + // Check each instance to see if it still exists. + // Only a 404 means "deleted": transient API errors abort this cleanup + // pass (via `?`) so a live instance is never removed from status by mistake. for instance_ref in current_instances { let instance_api: Api = Api::namespaced(client.clone(), &instance_ref.namespace); - let instance_exists = instance_api.get(&instance_ref.name).await.is_ok(); + let instance_exists = resource_exists(&instance_api, &instance_ref.name).await?; if !instance_exists { info!( @@ -150,41 +197,45 @@ pub async fn cleanup_stale_records( let mut records_to_keep: Vec = Vec::new(); let mut stale_count = 0; - // Check each record to see if it still exists + // Check each record to see if it still exists. + // Only a 404 means "deleted": transient API errors abort this cleanup pass + // (via `?`). Treating a transient error on a live record as "deleted" + // would trigger the SELF-HEALING path below and delete live DNS data from + // all primaries while the record CR still exists. for record_ref in current_records { let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?; let record_exists = match kind { DNSRecordKind::A => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::AAAA => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::TXT => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::CNAME => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::MX => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::NS => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::SRV => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } DNSRecordKind::CAA => { let api: Api = Api::namespaced(client.clone(), &record_ref.namespace); - api.get(&record_ref.name).await.is_ok() + resource_exists(&api, &record_ref.name).await? } }; diff --git a/src/reconcilers/dnszone/cleanup_tests.rs b/src/reconcilers/dnszone/cleanup_tests.rs index a0ab2e7b..a525c0c6 100644 --- a/src/reconcilers/dnszone/cleanup_tests.rs +++ b/src/reconcilers/dnszone/cleanup_tests.rs @@ -42,4 +42,60 @@ mod tests { // InstanceReference equality should ignore last_reconciled_at assert_eq!(inst1, inst2); } + + // ======================================================================== + // 404-aware existence checks (existence_from_get_result) + // ======================================================================== + + const HTTP_NOT_FOUND: u16 = 404; + + fn kube_api_error(code: u16) -> kube::Error { + kube::Error::Api( + kube::core::Status::failure("test error", "TestReason") + .with_code(code) + .boxed(), + ) + } + + #[test] + fn test_existence_from_get_result_ok_means_exists() { + let result: Result = Ok(42); + let exists = super::super::existence_from_get_result(result).unwrap(); + assert!(exists); + } + + #[test] + fn test_existence_from_get_result_404_means_deleted() { + let result: Result = Err(kube_api_error(HTTP_NOT_FOUND)); + let exists = super::super::existence_from_get_result(result).unwrap(); + assert!(!exists); + } + + #[test] + fn test_existence_from_get_result_transient_errors_propagate() { + // CRITICAL: transient API errors must NOT be conflated with "deleted" - + // doing so would trigger the self-healing path and delete live DNS data + const HTTP_TOO_MANY_REQUESTS: u16 = 429; + const HTTP_INTERNAL_SERVER_ERROR: u16 = 500; + const HTTP_GATEWAY_TIMEOUT: u16 = 504; + for code in [ + HTTP_TOO_MANY_REQUESTS, + HTTP_INTERNAL_SERVER_ERROR, + HTTP_GATEWAY_TIMEOUT, + ] { + let result: Result = Err(kube_api_error(code)); + assert!( + super::super::existence_from_get_result(result).is_err(), + "HTTP {code} must propagate as an error, not report deletion" + ); + } + } + + #[test] + fn test_existence_from_get_result_non_api_error_propagates() { + // kube errors that are not Api responses (e.g. connection failures) + // must also propagate + let result: Result = Err(kube::Error::LinesCodecMaxLineLengthExceeded); + assert!(super::super::existence_from_get_result(result).is_err()); + } } diff --git a/src/reconcilers/dnszone/discovery.rs b/src/reconcilers/dnszone/discovery.rs index 27560a60..4211a2aa 100644 --- a/src/reconcilers/dnszone/discovery.rs +++ b/src/reconcilers/dnszone/discovery.rs @@ -32,10 +32,16 @@ use crate::reconcilers::pagination::list_all_paginated; /// When `status.zoneRef` is set, the record is reconciled to BIND9. /// When `status.zoneRef` is cleared, the record reconciler marks it as `"NotSelected"`. /// +/// Before a record is untagged, its data is deleted from the zone's primary +/// BIND9 instances. If that DNS deletion fails, the record is kept selected +/// (and in `status.records`) so the cleanup is retried on the next +/// reconciliation instead of orphaning the data in BIND9. +/// /// # Arguments /// /// * `client` - Kubernetes API client for querying DNS records /// * `dnszone` - The `DNSZone` resource with label selectors +/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s /// /// # Returns /// @@ -49,6 +55,7 @@ use crate::reconcilers::pagination::list_all_paginated; pub async fn reconcile_zone_records( client: Client, dnszone: DNSZone, + stores: &crate::context::Stores, ) -> Result> { let namespace = dnszone.namespace().unwrap_or_default(); let spec = &dnszone.spec; @@ -150,39 +157,70 @@ pub async fn reconcile_zone_records( } // Untag previously matched records that no longer match or were deleted - // (in previous but not in current) - for prev_record_key in &previous_records { - if !current_records.contains(prev_record_key.as_str()) { - // Parse kind and name from "Kind/name" format - if let Some((kind, name)) = prev_record_key.split_once('/') { + // (in previous but not in current). Before untagging, delete the record's + // data from this zone's primary BIND9 instances - otherwise the data + // would stay live in BIND9 forever (the record reconciler only marks + // unselected records as NotSelected, it does not delete them). + let previous_refs: Vec = dnszone + .status + .as_ref() + .map(|s| s.records.clone()) + .unwrap_or_default(); + + // Lazily resolved (and cached) primary instances for this zone + let mut primary_refs_cache: Option> = None; + + for record_ref in unselected_previous_records(&previous_refs, ¤t_records) { + let kind = record_ref.kind.as_str(); + let name = record_ref.name.as_str(); + + warn!( + "Record no longer matches zone {} (unmatched or deleted): {} {}/{}", + zone_name, kind, namespace, name + ); + + // Delete the record data from BIND9 before untagging + if let Err(e) = cleanup_unselected_record_dns( + &client, + stores, + &dnszone, + &namespace, + &record_ref, + &mut primary_refs_cache, + ) + .await + { + // Do NOT untag: keep the record selected (zoneRef intact and + // present in status.records) so the DNS deletion is retried on + // the next reconciliation instead of orphaning data in BIND9. + warn!( + "Failed to delete DNS data for unselected record {} {}/{} from zone {}: {}. \ + Keeping record selected for retry.", + kind, namespace, name, zone_name, e + ); + all_record_refs.push(record_ref.clone()); + continue; + } + + // Try to untag the record, but don't fail if it was deleted + // If the record was deleted, the API will return NotFound, which is fine + if let Err(e) = untag_record_from_zone(&client, &namespace, kind, name, zone_name).await { + // Check if error is because record was deleted (NotFound) + if e.to_string().contains("NotFound") || e.to_string().contains("not found") { + info!( + "Record {} {}/{} was deleted, removing from zone {} status", + kind, namespace, name, zone_name + ); + } else { + // Other errors should be logged but not fail the reconciliation warn!( - "Record no longer matches zone {} (unmatched or deleted): {} {}/{}", - zone_name, kind, namespace, name + "Failed to untag record {} {}/{} from zone {}: {}", + kind, namespace, name, zone_name, e ); - - // Try to untag the record, but don't fail if it was deleted - // If the record was deleted, the API will return NotFound, which is fine - if let Err(e) = - untag_record_from_zone(&client, &namespace, kind, name, zone_name).await - { - // Check if error is because record was deleted (NotFound) - if e.to_string().contains("NotFound") || e.to_string().contains("not found") { - info!( - "Record {} {}/{} was deleted, removing from zone {} status", - kind, namespace, name, zone_name - ); - } else { - // Other errors should be logged but not fail the reconciliation - warn!( - "Failed to untag record {} {}/{} from zone {}: {}", - kind, namespace, name, zone_name, e - ); - } - } - // Continue regardless - the record will be removed from status.records - // when we return all_record_refs (which doesn't include this record) } } + // Continue regardless - the record will be removed from status.records + // when we return all_record_refs (which doesn't include this record) } // CRITICAL: Preserve existing timestamps for records that haven't changed @@ -210,6 +248,220 @@ pub async fn reconcile_zone_records( Ok(all_record_refs) } +/// HTTP status code returned by the Kubernetes API when a resource does not exist. +const HTTP_STATUS_NOT_FOUND: u16 = 404; + +/// Returns the previously matched record references that are no longer selected. +/// +/// # Arguments +/// +/// * `previous` - Record references from the zone's current `status.records[]` +/// * `current_keys` - Set of `"Kind/name"` keys for currently matched records +fn unselected_previous_records( + previous: &[crate::crd::RecordReferenceWithTimestamp], + current_keys: &HashSet, +) -> Vec { + previous + .iter() + .filter(|r| !current_keys.contains(&format!("{}/{}", r.kind, r.name))) + .cloned() + .collect() +} + +/// Maps a Kubernetes record kind (e.g., `"ARecord"`) to its hickory `RecordType`. +/// +/// # Errors +/// +/// Returns an error if the kind is not a known DNS record kind. +fn hickory_record_type_for_kind(kind: &str) -> Result { + use crate::crd::DNSRecordKind; + use hickory_proto::rr::RecordType; + + let record_kind = DNSRecordKind::try_from(kind) + .map_err(|e| anyhow::anyhow!("Unknown DNS record kind '{kind}': {e}"))?; + + Ok(match record_kind { + DNSRecordKind::A => RecordType::A, + DNSRecordKind::AAAA => RecordType::AAAA, + DNSRecordKind::TXT => RecordType::TXT, + DNSRecordKind::CNAME => RecordType::CNAME, + DNSRecordKind::MX => RecordType::MX, + DNSRecordKind::NS => RecordType::NS, + DNSRecordKind::SRV => RecordType::SRV, + DNSRecordKind::CAA => RecordType::CAA, + }) +} + +/// Builds a dynamic API client for a DNS record kind in the given namespace. +fn dynamic_record_api( + client: &Client, + namespace: &str, + kind: &str, +) -> kube::api::Api { + // Convert kind to plural resource name (e.g., "ARecord" -> "arecords") + let plural = format!("{}s", kind.to_lowercase()); + + let gvk = kube::core::GroupVersionKind { + group: "bindy.firestoned.io".to_string(), + version: "v1beta1".to_string(), + kind: kind.to_string(), + }; + + let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural); + + kube::api::Api::::namespaced_with( + client.clone(), + namespace, + &api_resource, + ) +} + +/// Deletes the DNS data of a record that is no longer selected by a zone. +/// +/// Called by `reconcile_zone_records()` before untagging a record. Without this, +/// unselecting a record (label/selector change) would leave its data live in +/// BIND9 forever, since the record reconciler only marks unselected records +/// as `NotSelected`. +/// +/// The deletion is skipped (returns `Ok`) when: +/// - The record resource no longer exists (its finalizer handles DNS cleanup) +/// - The record has no `status.zoneRef` (it was never published to DNS) +/// - The record's `status.zoneRef` points at a different zone (not ours to delete) +/// - The zone has no instances or no primary instances (nowhere to delete from) +/// +/// # Arguments +/// +/// * `client` - Kubernetes API client +/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s +/// * `dnszone` - The zone that previously selected this record +/// * `namespace` - Namespace of the record +/// * `record_ref` - Reference to the unselected record +/// * `primary_refs_cache` - Cache of the zone's primary instances (resolved once) +/// +/// # Errors +/// +/// Returns an error if the record cannot be fetched (other than `NotFound`), +/// primary instances cannot be resolved, or the DNS deletion fails. Callers +/// must NOT untag the record in that case, so the cleanup is retried. +async fn cleanup_unselected_record_dns( + client: &Client, + stores: &crate::context::Stores, + dnszone: &DNSZone, + namespace: &str, + record_ref: &crate::crd::RecordReferenceWithTimestamp, + primary_refs_cache: &mut Option>, +) -> Result<()> { + let kind = record_ref.kind.as_str(); + let name = record_ref.name.as_str(); + + // Fetch the record to read its published DNS name and zone ownership + let api = dynamic_record_api(client, namespace, kind); + let record = match api.get(name).await { + Ok(record) => record, + Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => { + // Record resource was deleted - its finalizer handles DNS cleanup + debug!( + "Record {} {}/{} no longer exists, skipping DNS cleanup", + kind, namespace, name + ); + return Ok(()); + } + Err(e) => { + return Err(anyhow::Error::from(e) + .context(format!("Failed to fetch {kind} {namespace}/{name}"))); + } + }; + + let record_json = serde_json::to_value(&record)?; + let status = record_json.get("status"); + + // No zoneRef means the record was never published to DNS + let Some(zone_ref) = status + .and_then(|s| s.get("zoneRef")) + .filter(|z| !z.is_null()) + else { + debug!( + "Record {} {}/{} has no status.zoneRef, skipping DNS cleanup", + kind, namespace, name + ); + return Ok(()); + }; + + // Only delete data for records this zone actually owns + let owned_by_this_zone = zone_ref.get("name").and_then(|v| v.as_str()) + == Some(dnszone.name_any().as_str()) + && zone_ref.get("namespace").and_then(|v| v.as_str()) + == Some(dnszone.namespace().unwrap_or_default().as_str()); + if !owned_by_this_zone { + debug!( + "Record {} {}/{} is owned by a different zone, skipping DNS cleanup", + kind, namespace, name + ); + return Ok(()); + } + + // Resolve this zone's primary instances once and cache across records + if primary_refs_cache.is_none() { + let Ok(instance_refs) = crate::reconcilers::dnszone::validation::get_instances_from_zone( + dnszone, + &stores.bind9_instances, + ) else { + // Zone has no instances - there is nowhere the data could live + debug!( + "Zone {} has no instances, skipping DNS cleanup for {} {}/{}", + dnszone.spec.zone_name, kind, namespace, name + ); + return Ok(()); + }; + + let primaries = + crate::reconcilers::dnszone::primary::filter_primary_instances(client, &instance_refs) + .await?; + *primary_refs_cache = Some(primaries); + } + + let primary_refs = primary_refs_cache.as_deref().unwrap_or_default(); + if primary_refs.is_empty() { + debug!( + "Zone {} has no primary instances, skipping DNS cleanup for {} {}/{}", + dnszone.spec.zone_name, kind, namespace, name + ); + return Ok(()); + } + + // The DNS name actually published (handles renames), falling back to spec.name + let record_name = status + .and_then(|s| s.get("publishedName")) + .and_then(|p| p.as_str()) + .or_else(|| { + record_json + .get("spec") + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + }) + .unwrap_or(name); + + let record_type_hickory = hickory_record_type_for_kind(kind)?; + + crate::reconcilers::records::delete_record_from_primaries( + client, + stores, + primary_refs, + &dnszone.spec.zone_name, + record_name, + record_type_hickory, + true, // fail_on_error: do not untag until the data is really gone + ) + .await?; + + info!( + "Deleted DNS data for unselected record {} {}/{} ('{}') from zone {}", + kind, namespace, name, record_name, dnszone.spec.zone_name + ); + + Ok(()) +} + /// Tags a DNS record with zone ownership by setting `status.zoneRef`. /// /// **Event-Driven Architecture**: This function is called when a `DNSZone`'s label selector @@ -241,25 +493,8 @@ async fn tag_record_with_zone( kind, namespace, name, zone_fqdn ); - // Convert kind to plural resource name (e.g., "ARecord" -> "arecords") - let plural = format!("{}s", kind.to_lowercase()); - - // Create GroupVersionKind for the resource - let gvk = kube::core::GroupVersionKind { - group: "bindy.firestoned.io".to_string(), - version: "v1beta1".to_string(), - kind: kind.to_string(), - }; - - // Use kube's Discovery API to create ApiResource - let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural); - // Create a dynamic API client - let api = kube::api::Api::::namespaced_with( - client.clone(), - namespace, - &api_resource, - ); + let api = dynamic_record_api(client, namespace, kind); // Create ZoneReference for status.zoneRef (event-driven architecture) let zone_ref = crate::crd::ZoneReference { @@ -326,25 +561,8 @@ async fn untag_record_from_zone( kind, namespace, name, previous_zone_fqdn ); - // Convert kind to plural resource name - let plural = format!("{}s", kind.to_lowercase()); - - // Create GroupVersionKind for the resource - let gvk = kube::core::GroupVersionKind { - group: "bindy.firestoned.io".to_string(), - version: "v1beta1".to_string(), - kind: kind.to_string(), - }; - - // Use kube's Discovery API to create ApiResource - let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural); - // Create a dynamic API client - let api = kube::api::Api::::namespaced_with( - client.clone(), - namespace, - &api_resource, - ); + let api = dynamic_record_api(client, namespace, kind); // Patch status to remove zoneRef (event-driven architecture uses status.zoneRef, not annotations) let status_patch = json!({ @@ -920,14 +1138,14 @@ pub async fn trigger_record_reconciliation( /// This wrapper function orchestrates record discovery and status updates: /// 1. Sets "Progressing" status condition /// 2. Calls `reconcile_zone_records()` to discover records -/// 3. Handles errors gracefully (non-fatal) -/// 4. Updates DNSZone status with discovered records +/// 3. Updates DNSZone status with discovered records /// /// # Arguments /// /// * `client` - Kubernetes API client /// * `dnszone` - The DNSZone resource being reconciled /// * `status_updater` - Status updater for setting conditions and records +/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s /// /// # Returns /// @@ -935,11 +1153,15 @@ pub async fn trigger_record_reconciliation( /// /// # Errors /// -/// Returns an error if critical failures occur (does not fail for record discovery errors) +/// Returns an error if record discovery fails (e.g., a transient record list +/// failure). The existing `status.records` list is left untouched in that case +/// so the watch mapper keeps working until discovery succeeds again; wiping it +/// on a transient error would orphan the zone's records. pub async fn discover_and_update_records( client: &kube::Client, dnszone: &crate::crd::DNSZone, status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, + stores: &crate::context::Stores, ) -> Result<(Vec, usize)> { let spec = &dnszone.spec; @@ -951,31 +1173,28 @@ pub async fn discover_and_update_records( "Discovering DNS records via label selectors", ); - // Discover records - let record_refs = match reconcile_zone_records(client.clone(), dnszone.clone()).await { - Ok(refs) => { - info!( - "Discovered {} DNS record(s) for zone {} via label selectors", - refs.len(), - spec.zone_name - ); - refs - } - Err(e) => { - // Record discovery failure is non-fatal - the zone itself is still configured + // Discover records. On failure, propagate the error WITHOUT touching + // status.records: overwriting it with an empty list on a transient list + // failure would break the record watch mapper until the next successful + // discovery. + let record_refs = reconcile_zone_records(client.clone(), dnszone.clone(), stores) + .await + .map_err(|e| { warn!( - "Failed to discover DNS records for zone {}: {}. Zone is configured but record discovery failed.", + "Failed to discover DNS records for zone {}: {}. Keeping existing status.records for retry.", spec.zone_name, e ); - status_updater.set_condition( - "Degraded", - "True", - "RecordDiscoveryFailed", - &format!("Zone configured but record discovery failed: {e}"), - ); - vec![] - } - }; + e.context(format!( + "Failed to discover DNS records for zone {}", + spec.zone_name + )) + })?; + + info!( + "Discovered {} DNS record(s) for zone {} via label selectors", + record_refs.len(), + spec.zone_name + ); let records_count = record_refs.len(); diff --git a/src/reconcilers/dnszone/discovery_tests.rs b/src/reconcilers/dnszone/discovery_tests.rs index 5913fba2..e46ab5a5 100644 --- a/src/reconcilers/dnszone/discovery_tests.rs +++ b/src/reconcilers/dnszone/discovery_tests.rs @@ -37,6 +37,88 @@ mod tests { assert!(selector.matches(&labels)); } + #[test] + fn test_hickory_record_type_for_kind_maps_all_kinds() { + use super::super::hickory_record_type_for_kind; + use hickory_proto::rr::RecordType; + + let expected = [ + ("ARecord", RecordType::A), + ("AAAARecord", RecordType::AAAA), + ("TXTRecord", RecordType::TXT), + ("CNAMERecord", RecordType::CNAME), + ("MXRecord", RecordType::MX), + ("NSRecord", RecordType::NS), + ("SRVRecord", RecordType::SRV), + ("CAARecord", RecordType::CAA), + ]; + + for (kind, record_type) in expected { + assert_eq!( + hickory_record_type_for_kind(kind).expect("known kind must map"), + record_type, + "kind {kind} must map to {record_type}" + ); + } + } + + #[test] + fn test_hickory_record_type_for_kind_rejects_unknown_kind() { + use super::super::hickory_record_type_for_kind; + + assert!(hickory_record_type_for_kind("PTRRecord").is_err()); + assert!(hickory_record_type_for_kind("").is_err()); + } + + #[test] + fn test_unselected_previous_records_partitions_correctly() { + use super::super::unselected_previous_records; + use crate::crd::RecordReferenceWithTimestamp; + use std::collections::HashSet; + + let make_ref = |kind: &str, name: &str| RecordReferenceWithTimestamp { + api_version: crate::constants::API_GROUP_VERSION.to_string(), + kind: kind.to_string(), + name: name.to_string(), + namespace: "default".to_string(), + record_name: Some("www".to_string()), + last_reconciled_at: None, + }; + + let previous = vec![ + make_ref("ARecord", "still-selected"), + make_ref("TXTRecord", "no-longer-selected"), + ]; + + let current: HashSet = ["ARecord/still-selected".to_string()].into_iter().collect(); + + let unselected = unselected_previous_records(&previous, ¤t); + + assert_eq!(unselected.len(), 1); + assert_eq!(unselected[0].kind, "TXTRecord"); + assert_eq!(unselected[0].name, "no-longer-selected"); + } + + #[test] + fn test_unselected_previous_records_empty_when_all_still_selected() { + use super::super::unselected_previous_records; + use crate::crd::RecordReferenceWithTimestamp; + use std::collections::HashSet; + + let previous = vec![RecordReferenceWithTimestamp { + api_version: crate::constants::API_GROUP_VERSION.to_string(), + kind: "ARecord".to_string(), + name: "web".to_string(), + namespace: "default".to_string(), + record_name: None, + last_reconciled_at: None, + }]; + + let current: HashSet = ["ARecord/web".to_string()].into_iter().collect(); + + assert!(unselected_previous_records(&previous, ¤t).is_empty()); + } + #[test] fn test_label_selector_no_match_different_value() { let mut selector_labels = BTreeMap::new(); diff --git a/src/reconcilers/dnszone/helpers.rs b/src/reconcilers/dnszone/helpers.rs index 9b4b28cd..a26dc474 100644 --- a/src/reconcilers/dnszone/helpers.rs +++ b/src/reconcilers/dnszone/helpers.rs @@ -8,14 +8,53 @@ use crate::crd::{DNSZone, InstanceReference}; use anyhow::{anyhow, Context as AnyhowContext, Result}; -use k8s_openapi::api::core::v1::{Endpoints, Secret}; +use k8s_openapi::api::core::v1::{Endpoints, Pod, Secret}; use kube::{Api, Client}; use std::collections::{HashMap, HashSet}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use super::types::{DuplicateZoneInfo, EndpointAddress}; use crate::bind9::RndcKeyData; +/// HTTP status code for "Not Found" responses from the Kubernetes API. +pub(crate) const HTTP_STATUS_NOT_FOUND: u16 = 404; + +/// How [`for_each_instance_endpoint_with_policy`] treats per-instance +/// RNDC-key and endpoint lookup failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointFailurePolicy { + /// Propagate RNDC-key and endpoint lookup failures immediately. + /// This is the correct behavior for normal reconciliation, where the + /// operation must be retried until the instance becomes reachable. + Strict, + /// Deletion-cleanup mode: an instance whose RNDC key Secret is gone or + /// that has no ready endpoints is skipped with a loud warning (the DNS + /// data on it is unreachable or already gone), while real API errors + /// (timeouts, 429, 5xx, ...) still fail the call so the next reconcile + /// retries. This prevents resources from being stuck Terminating behind + /// an instance that can never be cleaned up. + SkipUnavailable, +} + +/// Classifies an error from `load_rndc_key`/`get_endpoint` during DELETION cleanup. +/// +/// Returns `true` when the failure means the lookup target is gone or there is +/// nothing to operate on (safe to skip during deletion): +/// - a Kubernetes 404 (Secret or Endpoints object missing), or +/// - a non-Kubernetes error (e.g. "no ready endpoints found", malformed +/// Secret data) - conditions that will not be fixed by retrying the delete. +/// +/// Returns `false` for any other Kubernetes API error (timeout, 429, 5xx, ...) +/// which is potentially transient and must be retried instead of skipped. +#[must_use] +pub(crate) fn is_unavailable_for_deletion(err: &anyhow::Error) -> bool { + match err.downcast_ref::() { + Some(kube::Error::Api(ae)) => ae.code == HTTP_STATUS_NOT_FOUND, + Some(_) => false, + None => true, + } +} + /// Re-fetch a DNSZone to get the latest status. /// /// The `dnszone` parameter from the watch event might have stale status from the cache. @@ -213,6 +252,55 @@ pub async fn for_each_instance_endpoint( port_name: &str, operation: F, ) -> Result<(Option, usize)> +where + F: Fn(String, String, Option) -> Fut, + Fut: std::future::Future>, +{ + for_each_instance_endpoint_with_policy( + client, + instance_refs, + with_rndc_key, + port_name, + EndpointFailurePolicy::Strict, + operation, + ) + .await +} + +/// Execute an operation on all endpoints for a list of instance references, +/// with an explicit failure policy for per-instance lookups. +/// +/// Same as [`for_each_instance_endpoint`], but the caller chooses how to treat +/// RNDC-key and endpoint lookup failures (see [`EndpointFailurePolicy`]). +/// Deletion cleanup paths should use [`EndpointFailurePolicy::SkipUnavailable`] +/// so that a missing RNDC Secret or an instance with zero ready endpoints does +/// not block finalizer removal forever. +/// +/// # Arguments +/// +/// * `client` - Kubernetes API client +/// * `instance_refs` - List of instance references to process +/// * `with_rndc_key` - Whether to load and pass RNDC keys for each instance +/// * `port_name` - Port name to use for endpoints (e.g., "rndc-api", "dns-tcp") +/// * `policy` - How to treat per-instance RNDC-key/endpoint lookup failures +/// * `operation` - Async closure to execute for each endpoint +/// +/// # Returns +/// +/// * `Ok((first_endpoint, total_endpoints))` - First endpoint found and total count +/// +/// # Errors +/// +/// Returns an error if all operations fail, or if RNDC-key/endpoint lookups +/// fail and the policy does not allow skipping them. +pub async fn for_each_instance_endpoint_with_policy( + client: &Client, + instance_refs: &[crate::crd::InstanceReference], + with_rndc_key: bool, + port_name: &str, + policy: EndpointFailurePolicy, + operation: F, +) -> Result<(Option, usize)> where F: Fn(String, String, Option) -> Fut, Fut: std::future::Future>, @@ -229,19 +317,48 @@ where // Load RNDC key for this specific instance if requested let key_data = if with_rndc_key { - Some(load_rndc_key(client, &instance_ref.namespace, &instance_ref.name).await?) + match load_rndc_key(client, &instance_ref.namespace, &instance_ref.name).await { + Ok(key) => Some(key), + Err(e) + if policy == EndpointFailurePolicy::SkipUnavailable + && is_unavailable_for_deletion(&e) => + { + warn!( + "SKIPPING instance {}/{} during deletion cleanup: RNDC key unavailable ({e:#}). \ + DNS data on this instance cannot be cleaned up and may be orphaned.", + instance_ref.namespace, instance_ref.name + ); + continue; + } + Err(e) => return Err(e), + } } else { None }; // Get all endpoints for this instance's service - let endpoints = get_endpoint( + let endpoints = match get_endpoint( client, &instance_ref.namespace, &instance_ref.name, port_name, ) - .await?; + .await + { + Ok(eps) => eps, + Err(e) + if policy == EndpointFailurePolicy::SkipUnavailable + && is_unavailable_for_deletion(&e) => + { + warn!( + "SKIPPING instance {}/{} during deletion cleanup: no reachable endpoints ({e:#}). \ + DNS data on this instance cannot be cleaned up and may be orphaned.", + instance_ref.namespace, instance_ref.name + ); + continue; + } + Err(e) => return Err(e), + }; info!( "Found {} endpoint(s) for instance {}/{}", @@ -291,6 +408,45 @@ where Ok((first_endpoint, total_endpoints)) } +/// Extract the name and IP of a pod that is Running and has an IP assigned. +/// +/// Used when listing BIND9 pods for zone operations: pods that are not yet +/// Running, or that are so freshly scheduled they have no IP, must be SKIPPED +/// rather than failing the entire pod listing - a single Pending pod must not +/// abort operations against the healthy pods. +/// +/// # Arguments +/// +/// * `pod` - The pod to inspect +/// +/// # Returns +/// +/// `Some((name, ip))` if the pod is Running with an IP, `None` otherwise. +#[must_use] +pub fn running_pod_name_and_ip(pod: &Pod) -> Option<(String, String)> { + let pod_name = pod.metadata.name.as_deref().unwrap_or("unknown"); + + let phase = pod + .status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .unwrap_or("Unknown"); + if phase != "Running" { + tracing::debug!("Skipping pod {} (phase: {}, not running)", pod_name, phase); + return None; + } + + let Some(pod_ip) = pod.status.as_ref().and_then(|s| s.pod_ip.as_ref()) else { + tracing::debug!( + "Skipping pod {} without an IP address (likely just scheduled)", + pod_name + ); + return None; + }; + + Some((pod_name.to_string(), pod_ip.clone())) +} + /// Load RNDC key from the instance's secret. /// /// # Arguments diff --git a/src/reconcilers/dnszone/helpers_tests.rs b/src/reconcilers/dnszone/helpers_tests.rs index d487223a..7a66d789 100644 --- a/src/reconcilers/dnszone/helpers_tests.rs +++ b/src/reconcilers/dnszone/helpers_tests.rs @@ -282,4 +282,125 @@ mod tests { // // These would require kube::Client mocking infrastructure, which is typically // done in integration tests with test fixtures or mock servers. + + // ======================================================================== + // Deletion-cleanup failure classification (is_unavailable_for_deletion) + // ======================================================================== + + fn kube_api_error(code: u16) -> anyhow::Error { + anyhow::Error::from(kube::Error::Api( + kube::core::Status::failure("test error", "TestReason") + .with_code(code) + .boxed(), + )) + } + + #[test] + fn test_is_unavailable_for_deletion_kube_404_is_unavailable() { + // A missing Secret/Endpoints object (404) means the target is gone: + // safe to skip during deletion cleanup + let err = kube_api_error(HTTP_STATUS_NOT_FOUND); + assert!(is_unavailable_for_deletion(&err)); + } + + #[test] + fn test_is_unavailable_for_deletion_kube_transient_errors_are_not() { + // Timeouts / rate limits / server errors are potentially transient and + // must be retried, never skipped + const HTTP_TOO_MANY_REQUESTS: u16 = 429; + const HTTP_INTERNAL_SERVER_ERROR: u16 = 500; + const HTTP_SERVICE_UNAVAILABLE: u16 = 503; + for code in [ + HTTP_TOO_MANY_REQUESTS, + HTTP_INTERNAL_SERVER_ERROR, + HTTP_SERVICE_UNAVAILABLE, + ] { + let err = kube_api_error(code); + assert!( + !is_unavailable_for_deletion(&err), + "HTTP {code} must be treated as transient" + ); + } + } + + #[test] + fn test_is_unavailable_for_deletion_context_wrapped_kube_404() { + // load_rndc_key/get_endpoint wrap kube errors with .context(); the + // classification must see through the anyhow context chain + use anyhow::Context; + let err: anyhow::Error = Err::<(), _>(kube::Error::Api( + kube::core::Status::failure("secret not found", "NotFound") + .with_code(HTTP_STATUS_NOT_FOUND) + .boxed(), + )) + .context("Failed to get RNDC secret") + .unwrap_err(); + assert!(is_unavailable_for_deletion(&err)); + } + + #[test] + fn test_is_unavailable_for_deletion_non_kube_error_is_unavailable() { + // "No ready endpoints found" style errors are not fixed by retrying a + // deletion: skip with a warning + let err = anyhow::anyhow!("No ready endpoints found for service foo with port 'http'"); + assert!(is_unavailable_for_deletion(&err)); + } + + // ======================================================================== + // Pod listing helpers (running_pod_name_and_ip) + // ======================================================================== + + fn make_pod( + name: &str, + phase: Option<&str>, + ip: Option<&str>, + ) -> k8s_openapi::api::core::v1::Pod { + k8s_openapi::api::core::v1::Pod { + metadata: ObjectMeta { + name: Some(name.to_string()), + ..Default::default() + }, + status: Some(k8s_openapi::api::core::v1::PodStatus { + phase: phase.map(ToString::to_string), + pod_ip: ip.map(ToString::to_string), + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn test_running_pod_name_and_ip_running_with_ip() { + let pod = make_pod("pod-1", Some("Running"), Some("10.0.0.1")); + assert_eq!( + running_pod_name_and_ip(&pod), + Some(("pod-1".to_string(), "10.0.0.1".to_string())) + ); + } + + #[test] + fn test_running_pod_name_and_ip_pending_pod_is_skipped() { + let pod = make_pod("pod-pending", Some("Pending"), None); + assert_eq!(running_pod_name_and_ip(&pod), None); + } + + #[test] + fn test_running_pod_name_and_ip_running_without_ip_is_skipped() { + // A pod can briefly report Running before its IP is populated in the + // cache; it must be skipped, not fail the whole pod listing + let pod = make_pod("pod-no-ip", Some("Running"), None); + assert_eq!(running_pod_name_and_ip(&pod), None); + } + + #[test] + fn test_running_pod_name_and_ip_no_status() { + let pod = k8s_openapi::api::core::v1::Pod { + metadata: ObjectMeta { + name: Some("pod-nostatus".to_string()), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(running_pod_name_and_ip(&pod), None); + } } diff --git a/src/reconcilers/dnszone/primary.rs b/src/reconcilers/dnszone/primary.rs index fe00e6ca..14a94c0b 100644 --- a/src/reconcilers/dnszone/primary.rs +++ b/src/reconcilers/dnszone/primary.rs @@ -144,45 +144,23 @@ pub async fn find_all_primary_pods( ); for pod in &pods.items { - let pod_name = pod - .metadata - .name - .as_ref() - .ok_or_else(|| anyhow!("Pod has no name"))? - .clone(); - - // Get pod IP - let pod_ip = pod - .status - .as_ref() - .and_then(|s| s.pod_ip.as_ref()) - .ok_or_else(|| anyhow!("Pod {pod_name} has no IP address"))? - .clone(); - - // Check if pod is running - let phase = pod - .status - .as_ref() - .and_then(|s| s.phase.as_ref()) - .map(String::as_str); - - if phase == Some("Running") { - all_pod_infos.push(PodInfo { - name: pod_name.clone(), - ip: pod_ip.clone(), - instance_name: instance_name.clone(), - namespace: instance_namespace.clone(), - }); - debug!( - "Found running pod {} with IP {} in namespace {}", - pod_name, pod_ip, instance_namespace - ); - } else { - debug!( - "Skipping pod {} (phase: {:?}, not running)", - pod_name, phase - ); - } + // Skip pods that are not Running or have no IP yet (e.g. Pending + // pods that were just scheduled) instead of failing the whole + // listing - one new pod must not abort operations on healthy pods. + let Some((pod_name, pod_ip)) = super::helpers::running_pod_name_and_ip(pod) else { + continue; + }; + + all_pod_infos.push(PodInfo { + name: pod_name.clone(), + ip: pod_ip.clone(), + instance_name: instance_name.clone(), + namespace: instance_namespace.clone(), + }); + debug!( + "Found running pod {} with IP {} in namespace {}", + pod_name, pod_ip, instance_namespace + ); } } diff --git a/src/reconcilers/dnszone/secondary.rs b/src/reconcilers/dnszone/secondary.rs index cb0f36e8..eabf3bac 100644 --- a/src/reconcilers/dnszone/secondary.rs +++ b/src/reconcilers/dnszone/secondary.rs @@ -10,7 +10,7 @@ //! - Collecting secondary pod IPs //! - Executing operations on all secondary endpoints -use anyhow::{anyhow, Result}; +use anyhow::Result; use k8s_openapi::api::core::v1::Pod; use kube::{api::ListParams, Api, Client}; use tracing::{debug, error, info, warn}; @@ -213,45 +213,23 @@ async fn find_all_secondary_pods( ); for pod in &pods.items { - let pod_name = pod - .metadata - .name - .as_ref() - .ok_or_else(|| anyhow!("Pod has no name"))? - .clone(); - - // Get pod IP - let pod_ip = pod - .status - .as_ref() - .and_then(|s| s.pod_ip.as_ref()) - .ok_or_else(|| anyhow!("Pod {pod_name} has no IP address"))? - .clone(); - - // Check if pod is running - let phase = pod - .status - .as_ref() - .and_then(|s| s.phase.as_ref()) - .map(String::as_str); - - if phase == Some("Running") { - all_pod_infos.push(PodInfo { - name: pod_name.clone(), - ip: pod_ip.clone(), - instance_name: instance_name.clone(), - namespace: instance_namespace.clone(), - }); - debug!( - "Found running secondary pod {} with IP {} in namespace {}", - pod_name, pod_ip, instance_namespace - ); - } else { - debug!( - "Skipping secondary pod {} (phase: {:?}, not running)", - pod_name, phase - ); - } + // Skip pods that are not Running or have no IP yet (e.g. Pending + // pods that were just scheduled) instead of failing the whole + // listing - one new pod must not abort operations on healthy pods. + let Some((pod_name, pod_ip)) = super::helpers::running_pod_name_and_ip(pod) else { + continue; + }; + + all_pod_infos.push(PodInfo { + name: pod_name.clone(), + ip: pod_ip.clone(), + instance_name: instance_name.clone(), + namespace: instance_namespace.clone(), + }); + debug!( + "Found running secondary pod {} with IP {} in namespace {}", + pod_name, pod_ip, instance_namespace + ); } } diff --git a/src/reconcilers/dnszone/status_helpers.rs b/src/reconcilers/dnszone/status_helpers.rs index 214eba3c..46c837b7 100644 --- a/src/reconcilers/dnszone/status_helpers.rs +++ b/src/reconcilers/dnszone/status_helpers.rs @@ -46,47 +46,44 @@ pub async fn calculate_expected_instance_counts( Ok((expected_primary_count, expected_secondary_count)) } -/// Determine final zone status and apply conditions. +/// Set the final zone conditions in memory (no API call). /// -/// This function calculates the final Ready or Degraded status based on: +/// This function calculates the final Ready/Degraded/Progressing status based on: /// - Whether any degraded conditions were set during reconciliation -/// - Whether all expected instances were successfully configured +/// - Whether all expected INSTANCES were successfully configured (comparing +/// instance counts with instance counts - never endpoint counts, which would +/// mask partial pod failures) /// - Number of records discovered /// -/// The function then applies all accumulated status changes to the API server -/// in a single atomic operation. +/// The conditions always converge to a consistent triple: +/// - Success: `Ready=True`, `Degraded=False`, `Progressing=False` +/// - Failure/partial: `Ready=False`, `Degraded=True`, `Progressing=False` /// /// # Arguments /// /// * `status_updater` - Status updater with accumulated changes -/// * `client` - Kubernetes API client /// * `zone_name` - DNS zone name (e.g., "example.com") /// * `namespace` - Kubernetes namespace of the DNSZone resource /// * `name` - Name of the DNSZone resource -/// * `primary_count` - Number of primary instances successfully configured -/// * `secondary_count` - Number of secondary instances successfully configured +/// * `primary` - Primary configuration outcome (instance + endpoint counts) +/// * `secondary` - Secondary configuration outcome (instance + endpoint counts) /// * `expected_primary_count` - Expected number of primary instances /// * `expected_secondary_count` - Expected number of secondary instances /// * `records_count` - Number of DNS records discovered /// * `generation` - Metadata generation to set as observed -/// -/// # Errors -/// -/// Returns an error if status update fails to apply #[allow(clippy::too_many_arguments)] -pub async fn finalize_zone_status( +pub fn set_final_zone_conditions( status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, - client: &Client, zone_name: &str, namespace: &str, name: &str, - primary_count: usize, - secondary_count: usize, + primary: super::types::ZoneConfigOutcome, + secondary: super::types::ZoneConfigOutcome, expected_primary_count: usize, expected_secondary_count: usize, records_count: usize, generation: Option, -) -> Result<()> { +) { // Set observed generation status_updater.set_observed_generation(generation); @@ -94,36 +91,46 @@ pub async fn finalize_zone_status( // Only set Ready=True if there were NO degraded conditions during reconciliation // AND all expected instances were successfully configured if status_updater.has_degraded_condition() { - // Keep the Degraded condition that was already set, don't overwrite with Ready + // Keep the Degraded condition that was already set, but make the + // condition triple consistent: a stale Ready=True from a previous + // successful reconciliation must not survive a failure. + status_updater.set_condition( + "Ready", + "False", + "ReconcileDegraded", + &format!("Zone {zone_name} reconciliation completed with degraded state - see Degraded condition for details"), + ); tracing::info!( "DNSZone {}/{} reconciliation completed with degraded state - will retry faster", namespace, name ); - } else if primary_count < expected_primary_count || secondary_count < expected_secondary_count { - // Not all instances were configured - set Degraded condition - status_updater.set_condition( - "Degraded", - "True", - "PartialReconciliation", - &format!( - "Zone {} configured on {}/{} primary and {}/{} secondary instance(s) - {} instance(s) pending", - zone_name, - primary_count, - expected_primary_count, - secondary_count, - expected_secondary_count, - (expected_primary_count - primary_count) - + (expected_secondary_count - secondary_count) - ), + } else if primary.instances_configured < expected_primary_count + || secondary.instances_configured < expected_secondary_count + { + // Not all INSTANCES were configured - set Degraded and Ready=False. + // Comparing instance counts (not endpoint counts) ensures an instance + // that received the zone on none of its pods is not masked by another + // instance with multiple successful pod endpoints. + let message = format!( + "Zone {} configured on {}/{} primary and {}/{} secondary instance(s) - {} instance(s) pending", + zone_name, + primary.instances_configured, + expected_primary_count, + secondary.instances_configured, + expected_secondary_count, + expected_primary_count.saturating_sub(primary.instances_configured) + + expected_secondary_count.saturating_sub(secondary.instances_configured) ); + status_updater.set_condition("Degraded", "True", "PartialReconciliation", &message); + status_updater.set_condition("Ready", "False", "PartialReconciliation", &message); tracing::info!( "DNSZone {}/{} partially configured: {}/{} primaries, {}/{} secondaries", namespace, name, - primary_count, + primary.instances_configured, expected_primary_count, - secondary_count, + secondary.instances_configured, expected_secondary_count ); } else { @@ -133,14 +140,79 @@ pub async fn finalize_zone_status( "True", "ReconcileSucceeded", &format!( - "Zone {} configured on {} primary and {} secondary instance(s), discovered {} DNS record(s)", - zone_name, primary_count, secondary_count, records_count + "Zone {} configured on {} primary and {} secondary instance(s) ({} endpoint(s)), discovered {} DNS record(s)", + zone_name, + primary.instances_configured, + secondary.instances_configured, + primary.endpoints_configured + secondary.endpoints_configured, + records_count ), ); // Clear any stale Degraded condition from previous failures status_updater.clear_degraded_condition(); } + // The reconciliation attempt has finished either way - resolve the + // Progressing condition set at the start of BIND9 configuration so it + // does not stay True forever. + status_updater.set_condition( + "Progressing", + "False", + "ReconcileComplete", + "Reconciliation attempt finished", + ); +} + +/// Determine final zone status and apply conditions. +/// +/// Sets the final condition triple via [`set_final_zone_conditions`] and then +/// applies all accumulated status changes to the API server in a single +/// atomic operation. +/// +/// # Arguments +/// +/// * `status_updater` - Status updater with accumulated changes +/// * `client` - Kubernetes API client +/// * `zone_name` - DNS zone name (e.g., "example.com") +/// * `namespace` - Kubernetes namespace of the DNSZone resource +/// * `name` - Name of the DNSZone resource +/// * `primary` - Primary configuration outcome (instance + endpoint counts) +/// * `secondary` - Secondary configuration outcome (instance + endpoint counts) +/// * `expected_primary_count` - Expected number of primary instances +/// * `expected_secondary_count` - Expected number of secondary instances +/// * `records_count` - Number of DNS records discovered +/// * `generation` - Metadata generation to set as observed +/// +/// # Errors +/// +/// Returns an error if status update fails to apply +#[allow(clippy::too_many_arguments)] +pub async fn finalize_zone_status( + status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater, + client: &Client, + zone_name: &str, + namespace: &str, + name: &str, + primary: super::types::ZoneConfigOutcome, + secondary: super::types::ZoneConfigOutcome, + expected_primary_count: usize, + expected_secondary_count: usize, + records_count: usize, + generation: Option, +) -> Result<()> { + set_final_zone_conditions( + status_updater, + zone_name, + namespace, + name, + primary, + secondary, + expected_primary_count, + expected_secondary_count, + records_count, + generation, + ); + // Apply all status changes in a single atomic operation status_updater.apply(client).await?; diff --git a/src/reconcilers/dnszone/status_helpers_tests.rs b/src/reconcilers/dnszone/status_helpers_tests.rs index 40446a67..de64d7c7 100644 --- a/src/reconcilers/dnszone/status_helpers_tests.rs +++ b/src/reconcilers/dnszone/status_helpers_tests.rs @@ -120,4 +120,183 @@ mod tests { assert_eq!(refs.len(), 3); } + + // ======================================================================== + // set_final_zone_conditions - final condition triple (bugs: endpoint-vs- + // instance unit mismatch, Ready never cleared, Progressing never resolved) + // ======================================================================== + + use crate::crd::{Condition, DNSZone, DNSZoneSpec, SOARecord}; + use crate::reconcilers::dnszone::status_helpers::set_final_zone_conditions; + use crate::reconcilers::dnszone::types::ZoneConfigOutcome; + use crate::reconcilers::status::DNSZoneStatusUpdater; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + #[allow(deprecated)] + fn create_test_zone() -> DNSZone { + DNSZone { + metadata: ObjectMeta { + name: Some("test-zone".to_string()), + namespace: Some("default".to_string()), + generation: Some(3), + ..Default::default() + }, + spec: DNSZoneSpec { + zone_name: "example.com".to_string(), + cluster_ref: None, + soa_record: SOARecord { + primary_ns: "ns1.example.com.".to_string(), + admin_email: "admin.example.com.".to_string(), + serial: 2_024_010_101, + refresh: 3600, + retry: 600, + expire: 604_800, + negative_ttl: 86400, + }, + ttl: None, + name_servers: None, + name_server_ips: None, + records_from: None, + bind9_instances_from: None, + dnssec_policy: None, + }, + status: None, + } + } + + fn find_condition<'a>(conditions: &'a [Condition], r#type: &str) -> &'a Condition { + conditions + .iter() + .find(|c| c.r#type == r#type) + .unwrap_or_else(|| panic!("condition {type} not found", type = r#type)) + } + + #[test] + fn test_set_final_zone_conditions_success_converges_triple() { + let zone = create_test_zone(); + let mut updater = DNSZoneStatusUpdater::new(&zone); + // Simulate the Progressing=True set at the start of BIND9 configuration + updater.set_condition("Progressing", "True", "PrimaryReconciling", "working"); + + set_final_zone_conditions( + &mut updater, + "example.com", + "default", + "test-zone", + ZoneConfigOutcome { + instances_configured: 2, + endpoints_configured: 4, + }, + ZoneConfigOutcome { + instances_configured: 1, + endpoints_configured: 2, + }, + 2, // expected primaries + 1, // expected secondaries + 5, // records + Some(3), + ); + + let conditions = updater.conditions(); + assert_eq!(find_condition(conditions, "Ready").status, "True"); + assert_eq!(find_condition(conditions, "Degraded").status, "False"); + // Progressing must be resolved on success - it previously stayed True forever + assert_eq!(find_condition(conditions, "Progressing").status, "False"); + } + + #[test] + fn test_set_final_zone_conditions_partial_instance_failure_not_masked_by_endpoints() { + // THE BUG: instance A has 2 pod endpoints configured, instance B got + // nothing. Endpoint count (2) >= expected instance count (2) previously + // masked the failure and reported Ready=True. Comparing INSTANCE units + // must report Degraded instead. + let zone = create_test_zone(); + let mut updater = DNSZoneStatusUpdater::new(&zone); + + set_final_zone_conditions( + &mut updater, + "example.com", + "default", + "test-zone", + ZoneConfigOutcome { + instances_configured: 1, // only instance A fully configured + endpoints_configured: 2, // ... but it has 2 endpoints + }, + ZoneConfigOutcome::default(), + 2, // expected primaries: A and B + 0, + 0, + Some(3), + ); + + let conditions = updater.conditions(); + let degraded = find_condition(conditions, "Degraded"); + assert_eq!(degraded.status, "True"); + assert_eq!(degraded.reason.as_deref(), Some("PartialReconciliation")); + assert!(degraded.message.as_deref().unwrap().contains("1/2 primary")); + // Ready must be False on partial configuration + assert_eq!(find_condition(conditions, "Ready").status, "False"); + assert_eq!(find_condition(conditions, "Progressing").status, "False"); + } + + #[test] + fn test_set_final_zone_conditions_degraded_clears_stale_ready() { + // A zone that was Ready=True from a previous reconciliation must have + // Ready set to False when this reconciliation set a Degraded condition + let mut zone = create_test_zone(); + zone.status = Some(crate::crd::DNSZoneStatus { + conditions: vec![Condition { + r#type: "Ready".to_string(), + status: "True".to_string(), + reason: Some("ReconcileSucceeded".to_string()), + message: Some("previously fine".to_string()), + last_transition_time: None, + }], + ..Default::default() + }); + let mut updater = DNSZoneStatusUpdater::new(&zone); + updater.set_condition("Degraded", "True", "SecondaryFailed", "secondary broke"); + + set_final_zone_conditions( + &mut updater, + "example.com", + "default", + "test-zone", + ZoneConfigOutcome { + instances_configured: 1, + endpoints_configured: 1, + }, + ZoneConfigOutcome::default(), + 1, + 0, + 0, + Some(3), + ); + + let conditions = updater.conditions(); + assert_eq!(find_condition(conditions, "Degraded").status, "True"); + // The stale Ready=True must not survive a degraded reconciliation + assert_eq!(find_condition(conditions, "Ready").status, "False"); + assert_eq!(find_condition(conditions, "Progressing").status, "False"); + } + + #[test] + fn test_set_failure_conditions_sets_consistent_triple() { + let zone = create_test_zone(); + let mut updater = DNSZoneStatusUpdater::new(&zone); + updater.set_condition("Progressing", "True", "PrimaryReconciling", "working"); + + crate::reconcilers::dnszone::bind9_config::set_failure_conditions( + &mut updater, + "PrimaryFailed", + "could not configure primaries", + ); + + let conditions = updater.conditions(); + let ready = find_condition(conditions, "Ready"); + assert_eq!(ready.status, "False"); + assert_eq!(ready.reason.as_deref(), Some("PrimaryFailed")); + assert_eq!(find_condition(conditions, "Degraded").status, "True"); + assert_eq!(find_condition(conditions, "Progressing").status, "False"); + } } diff --git a/src/reconcilers/dnszone/types.rs b/src/reconcilers/dnszone/types.rs index a0baff47..9d4ee039 100644 --- a/src/reconcilers/dnszone/types.rs +++ b/src/reconcilers/dnszone/types.rs @@ -44,3 +44,18 @@ pub struct EndpointAddress { /// Container port number pub port: i32, } + +/// Outcome of configuring a zone across a set of BIND9 instances. +/// +/// Tracks success in two different units so readiness can be computed in +/// INSTANCE units (comparable with the expected instance counts) while still +/// reporting per-endpoint detail for observability. An instance counts as +/// configured only if ALL of its ready endpoints accepted the zone. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ZoneConfigOutcome { + /// Number of instances where EVERY ready endpoint accepted the zone. + pub instances_configured: usize, + /// Total number of endpoints that accepted the zone (including + /// endpoints where the zone already existed). + pub endpoints_configured: usize, +} diff --git a/src/reconcilers/finalizers.rs b/src/reconcilers/finalizers.rs index ac5c797a..9df88fba 100644 --- a/src/reconcilers/finalizers.rs +++ b/src/reconcilers/finalizers.rs @@ -39,13 +39,100 @@ //! } //! ``` -use anyhow::Result; +use anyhow::{anyhow, Context as AnyhowContext, Result}; use kube::api::{Patch, PatchParams}; use kube::core::{ClusterResourceScope, NamespaceResourceScope}; use kube::{Api, Client, Resource, ResourceExt}; use serde_json::json; use tracing::info; +/// Builds the JSON Patch that atomically adds `finalizer` to a resource. +/// +/// Mirrors the pattern used by kube-runtime's own finalizer helper: +/// - When the resource has no finalizers, the patch `test`s that +/// `/metadata/finalizers` is absent (null) before creating the array. This +/// guarantees a racing writer that added a finalizer first is not clobbered. +/// - When finalizers exist, JSON Patch has no test-for-absence, so the patch +/// `test`s `/metadata/resourceVersion` instead and appends with the `-` +/// (end-of-array) pointer, never rewriting existing entries. +/// +/// If either `test` fails on the server (concurrent modification), the API +/// call fails and the caller must requeue; the next reconciliation retries +/// with fresh state. +/// +/// # Arguments +/// +/// * `existing_finalizers` - Finalizers currently on the (possibly stale) object +/// * `resource_version` - The object's `metadata.resourceVersion` +/// * `finalizer` - The finalizer string to add +/// +/// # Errors +/// +/// Returns an error if the resource has existing finalizers but no +/// `resourceVersion` (cannot build a safe guarded patch), or if patch +/// serialization fails. +pub(crate) fn build_add_finalizer_patch( + existing_finalizers: &[String], + resource_version: Option<&str>, + finalizer: &str, +) -> Result { + let operations = if existing_finalizers.is_empty() { + json!([ + {"op": "test", "path": "/metadata/finalizers", "value": null}, + {"op": "add", "path": "/metadata/finalizers", "value": [finalizer]}, + ]) + } else { + let resource_version = resource_version.ok_or_else(|| { + anyhow!("resource has no resourceVersion; cannot safely add finalizer {finalizer}") + })?; + json!([ + {"op": "test", "path": "/metadata/resourceVersion", "value": resource_version}, + {"op": "add", "path": "/metadata/finalizers/-", "value": finalizer}, + ]) + }; + + serde_json::from_value(operations).context("failed to build add-finalizer JSON Patch") +} + +/// Builds the JSON Patch that atomically removes `finalizer` from a resource. +/// +/// Mirrors the pattern used by kube-runtime's own finalizer helper: the patch +/// `test`s that the exact array index still contains our finalizer before +/// removing that index. If a racing writer added or removed a finalizer in the +/// meantime (shifting indices), the `test` fails server-side and nothing is +/// removed - so a foreign finalizer can never be dropped by accident. +/// +/// # Arguments +/// +/// * `existing_finalizers` - Finalizers currently on the (possibly stale) object +/// * `finalizer` - The finalizer string to remove +/// +/// # Returns +/// +/// `Ok(None)` when the finalizer is not present (nothing to do). +/// +/// # Errors +/// +/// Returns an error if patch serialization fails. +pub(crate) fn build_remove_finalizer_patch( + existing_finalizers: &[String], + finalizer: &str, +) -> Result> { + let Some(index) = existing_finalizers.iter().position(|f| f == finalizer) else { + return Ok(None); + }; + + let finalizer_path = format!("/metadata/finalizers/{index}"); + let operations = json!([ + {"op": "test", "path": finalizer_path, "value": finalizer}, + {"op": "remove", "path": finalizer_path}, + ]); + + serde_json::from_value(operations) + .map(Some) + .context("failed to build remove-finalizer JSON Patch") +} + /// Trait for resources that require cleanup operations when being deleted. /// /// Implement this trait to define custom cleanup logic that should run @@ -93,11 +180,19 @@ pub trait FinalizerCleanup: Resource + ResourceExt + Clone { /// /// Returns `Ok(())` if the finalizer was added or already present. /// +/// # Concurrency +/// +/// The patch is a JSON Patch guarded by a `test` operation (mirroring +/// kube-runtime's finalizer helper), so a racing writer's finalizer edits are +/// never clobbered. On a conflict the API call fails and the returned error +/// triggers a requeue; the next reconciliation retries with fresh state. +/// /// # Errors /// /// Returns an error if: /// - The resource has no namespace (for namespaced resources) -/// - The API patch operation fails +/// - The API patch operation fails, including when a concurrent writer +/// modified `metadata.finalizers` and the guarded patch was rejected /// /// # Example /// @@ -122,38 +217,45 @@ where let namespace = resource.namespace().unwrap_or_default(); let name = resource.name_any(); - // Check if finalizer is already present - if resource - .meta() - .finalizers - .as_ref() - .is_none_or(|f| !f.contains(&finalizer.to_string())) - { - info!( - "Adding finalizer {} to {}/{} {}", - finalizer, - namespace, - name, - T::kind(&()) - ); - - let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - finalizers.push(finalizer.to_string()); + let finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - let api: Api = Api::namespaced(client.clone(), &namespace); - let patch = json!({ "metadata": { "finalizers": finalizers } }); - api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) - .await?; - - info!( - "Successfully added finalizer {} to {}/{} {}", - finalizer, - namespace, - name, - T::kind(&()) - ); + // Early return: finalizer already present + if finalizers.iter().any(|f| f == finalizer) { + return Ok(()); } + info!( + "Adding finalizer {} to {}/{} {}", + finalizer, + namespace, + name, + T::kind(&()) + ); + + let patch = build_add_finalizer_patch( + &finalizers, + resource.meta().resource_version.as_deref(), + finalizer, + )?; + + let api: Api = Api::namespaced(client.clone(), &namespace); + api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch)) + .await + .with_context(|| { + format!( + "failed to add finalizer {finalizer} to {namespace}/{name} \ + (possibly a concurrent finalizer edit; will retry on next reconciliation)" + ) + })?; + + info!( + "Successfully added finalizer {} to {}/{} {}", + finalizer, + namespace, + name, + T::kind(&()) + ); + Ok(()) } @@ -176,11 +278,20 @@ where /// /// Returns `Ok(())` if the finalizer was removed or already absent. /// +/// # Concurrency +/// +/// The patch is a JSON Patch that `test`s the exact array index before +/// removing it (mirroring kube-runtime's finalizer helper), so a racing +/// writer's finalizer edits are never clobbered. On a conflict the API call +/// fails and the returned error triggers a requeue; the next reconciliation +/// retries with fresh state. +/// /// # Errors /// /// Returns an error if: /// - The resource has no namespace (for namespaced resources) -/// - The API patch operation fails +/// - The API patch operation fails, including when a concurrent writer +/// modified `metadata.finalizers` and the guarded patch was rejected pub async fn remove_finalizer(client: &Client, resource: &T, finalizer: &str) -> Result<()> where T: Resource @@ -193,37 +304,38 @@ where let namespace = resource.namespace().unwrap_or_default(); let name = resource.name_any(); - // Check if finalizer is present - if resource - .meta() - .finalizers - .as_ref() - .is_some_and(|f| f.contains(&finalizer.to_string())) - { - info!( - "Removing finalizer {} from {}/{} {}", - finalizer, - namespace, - name, - T::kind(&()) - ); - - let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - finalizers.retain(|f| f != finalizer); - - let api: Api = Api::namespaced(client.clone(), &namespace); - let patch = json!({ "metadata": { "finalizers": finalizers } }); - api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) - .await?; - - info!( - "Successfully removed finalizer {} from {}/{} {}", - finalizer, - namespace, - name, - T::kind(&()) - ); - } + let finalizers = resource.meta().finalizers.clone().unwrap_or_default(); + + // Early return: finalizer already absent + let Some(patch) = build_remove_finalizer_patch(&finalizers, finalizer)? else { + return Ok(()); + }; + + info!( + "Removing finalizer {} from {}/{} {}", + finalizer, + namespace, + name, + T::kind(&()) + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch)) + .await + .with_context(|| { + format!( + "failed to remove finalizer {finalizer} from {namespace}/{name} \ + (possibly a concurrent finalizer edit; will retry on next reconciliation)" + ) + })?; + + info!( + "Successfully removed finalizer {} from {}/{} {}", + finalizer, + namespace, + name, + T::kind(&()) + ); Ok(()) } @@ -330,9 +442,18 @@ where /// /// Returns `Ok(())` if the finalizer was added or already present. /// +/// # Concurrency +/// +/// The patch is a JSON Patch guarded by a `test` operation (mirroring +/// kube-runtime's finalizer helper), so a racing writer's finalizer edits are +/// never clobbered. On a conflict the API call fails and the returned error +/// triggers a requeue; the next reconciliation retries with fresh state. +/// /// # Errors /// -/// Returns an error if the API patch operation fails. +/// Returns an error if the API patch operation fails, including when a +/// concurrent writer modified `metadata.finalizers` and the guarded patch +/// was rejected. /// /// # Example /// @@ -360,36 +481,43 @@ where { let name = resource.name_any(); - // Check if finalizer is already present - if resource - .meta() - .finalizers - .as_ref() - .is_none_or(|f| !f.contains(&finalizer.to_string())) - { - info!( - "Adding finalizer {} to {} {}", - finalizer, - T::kind(&()), - name - ); + let finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - finalizers.push(finalizer.to_string()); - - let api: Api = Api::all(client.clone()); - let patch = json!({ "metadata": { "finalizers": finalizers } }); - api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) - .await?; - - info!( - "Successfully added finalizer {} to {} {}", - finalizer, - T::kind(&()), - name - ); + // Early return: finalizer already present + if finalizers.iter().any(|f| f == finalizer) { + return Ok(()); } + info!( + "Adding finalizer {} to {} {}", + finalizer, + T::kind(&()), + name + ); + + let patch = build_add_finalizer_patch( + &finalizers, + resource.meta().resource_version.as_deref(), + finalizer, + )?; + + let api: Api = Api::all(client.clone()); + api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch)) + .await + .with_context(|| { + format!( + "failed to add finalizer {finalizer} to {name} \ + (possibly a concurrent finalizer edit; will retry on next reconciliation)" + ) + })?; + + info!( + "Successfully added finalizer {} to {} {}", + finalizer, + T::kind(&()), + name + ); + Ok(()) } @@ -412,9 +540,19 @@ where /// /// Returns `Ok(())` if the finalizer was removed or already absent. /// +/// # Concurrency +/// +/// The patch is a JSON Patch that `test`s the exact array index before +/// removing it (mirroring kube-runtime's finalizer helper), so a racing +/// writer's finalizer edits are never clobbered. On a conflict the API call +/// fails and the returned error triggers a requeue; the next reconciliation +/// retries with fresh state. +/// /// # Errors /// -/// Returns an error if the API patch operation fails. +/// Returns an error if the API patch operation fails, including when a +/// concurrent writer modified `metadata.finalizers` and the guarded patch +/// was rejected. pub async fn remove_cluster_finalizer( client: &Client, resource: &T, @@ -430,35 +568,36 @@ where { let name = resource.name_any(); - // Check if finalizer is present - if resource - .meta() - .finalizers - .as_ref() - .is_some_and(|f| f.contains(&finalizer.to_string())) - { - info!( - "Removing finalizer {} from {} {}", - finalizer, - T::kind(&()), - name - ); - - let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default(); - finalizers.retain(|f| f != finalizer); - - let api: Api = Api::all(client.clone()); - let patch = json!({ "metadata": { "finalizers": finalizers } }); - api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) - .await?; - - info!( - "Successfully removed finalizer {} from {} {}", - finalizer, - T::kind(&()), - name - ); - } + let finalizers = resource.meta().finalizers.clone().unwrap_or_default(); + + // Early return: finalizer already absent + let Some(patch) = build_remove_finalizer_patch(&finalizers, finalizer)? else { + return Ok(()); + }; + + info!( + "Removing finalizer {} from {} {}", + finalizer, + T::kind(&()), + name + ); + + let api: Api = Api::all(client.clone()); + api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch)) + .await + .with_context(|| { + format!( + "failed to remove finalizer {finalizer} from {name} \ + (possibly a concurrent finalizer edit; will retry on next reconciliation)" + ) + })?; + + info!( + "Successfully removed finalizer {} from {} {}", + finalizer, + T::kind(&()), + name + ); Ok(()) } diff --git a/src/reconcilers/finalizers_tests.rs b/src/reconcilers/finalizers_tests.rs index bab554cb..ff5329f6 100644 --- a/src/reconcilers/finalizers_tests.rs +++ b/src/reconcilers/finalizers_tests.rs @@ -730,4 +730,87 @@ mod tests { .as_ref() .is_some_and(|f| f.contains(&TEST_FINALIZER.to_string()))); } + + // ======================================================================== + // Guarded JSON Patch builders (concurrency-safe finalizer add/remove) + // ======================================================================== + + use crate::reconcilers::finalizers::{build_add_finalizer_patch, build_remove_finalizer_patch}; + + #[test] + fn test_build_add_finalizer_patch_empty_finalizers_tests_for_absence() { + // With no existing finalizers the patch must test that the array is + // absent before creating it, so a racing writer's fresh finalizer is + // never clobbered + let patch = build_add_finalizer_patch(&[], Some("42"), TEST_FINALIZER).unwrap(); + let value = serde_json::to_value(&patch).unwrap(); + + assert_eq!( + value, + serde_json::json!([ + {"op": "test", "path": "/metadata/finalizers", "value": null}, + {"op": "add", "path": "/metadata/finalizers", "value": [TEST_FINALIZER]}, + ]) + ); + } + + #[test] + fn test_build_add_finalizer_patch_existing_finalizers_appends_with_rv_guard() { + // With existing finalizers the patch must guard on resourceVersion and + // APPEND (path "-") rather than rewriting the whole array + let existing = vec!["other.io/finalizer".to_string()]; + let patch = build_add_finalizer_patch(&existing, Some("42"), TEST_FINALIZER).unwrap(); + let value = serde_json::to_value(&patch).unwrap(); + + assert_eq!( + value, + serde_json::json!([ + {"op": "test", "path": "/metadata/resourceVersion", "value": "42"}, + {"op": "add", "path": "/metadata/finalizers/-", "value": TEST_FINALIZER}, + ]) + ); + } + + #[test] + fn test_build_add_finalizer_patch_existing_finalizers_requires_resource_version() { + let existing = vec!["other.io/finalizer".to_string()]; + assert!(build_add_finalizer_patch(&existing, None, TEST_FINALIZER).is_err()); + } + + #[test] + fn test_build_remove_finalizer_patch_tests_exact_index_content() { + // The patch must test the exact index content before removing it, so a + // concurrent add/remove (shifting indices) fails the test server-side + // instead of removing a FOREIGN finalizer + let existing = vec![ + "other.io/finalizer".to_string(), + TEST_FINALIZER.to_string(), + "third.io/finalizer".to_string(), + ]; + let patch = build_remove_finalizer_patch(&existing, TEST_FINALIZER) + .unwrap() + .expect("finalizer is present, patch expected"); + let value = serde_json::to_value(&patch).unwrap(); + + assert_eq!( + value, + serde_json::json!([ + {"op": "test", "path": "/metadata/finalizers/1", "value": TEST_FINALIZER}, + {"op": "remove", "path": "/metadata/finalizers/1"}, + ]) + ); + } + + #[test] + fn test_build_remove_finalizer_patch_absent_finalizer_returns_none() { + let existing = vec!["other.io/finalizer".to_string()]; + let patch = build_remove_finalizer_patch(&existing, TEST_FINALIZER).unwrap(); + assert!(patch.is_none()); + } + + #[test] + fn test_build_remove_finalizer_patch_empty_list_returns_none() { + let patch = build_remove_finalizer_patch(&[], TEST_FINALIZER).unwrap(); + assert!(patch.is_none()); + } } diff --git a/src/reconcilers/records/mod.rs b/src/reconcilers/records/mod.rs index 6ba358ac..9f547b41 100644 --- a/src/reconcilers/records/mod.rs +++ b/src/reconcilers/records/mod.rs @@ -152,6 +152,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; return Ok(None); @@ -182,6 +183,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; return Ok(None); @@ -210,6 +212,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; return Ok(None); @@ -240,6 +243,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; return Ok(None); @@ -262,6 +266,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; return Ok(None); @@ -389,9 +394,15 @@ trait ReconcilableRecord: /// Get the record's spec fn get_spec(&self) -> &Self::Spec; + /// Get the record's status, if any + fn get_status(&self) -> Option<&crate::crd::RecordStatus>; + /// Get the record type name (e.g., "A", "TXT", "AAAA") for logging fn record_type_name() -> &'static str; + /// Get the `hickory_proto` record type used for DNS deletion operations + fn record_type_hickory() -> hickory_proto::rr::RecordType; + /// Create the BIND9 operation from the spec fn create_operation(spec: &Self::Spec) -> Self::Operation; @@ -400,6 +411,13 @@ trait ReconcilableRecord: /// Get the TTL from the spec fn get_ttl(spec: &Self::Spec) -> Option; + + /// Comma-separated display addresses for `status.addresses` (A/AAAA only). + /// + /// Returns `None` for record types that do not publish addresses. + fn get_display_addresses(_spec: &Self::Spec) -> Option { + None + } } /// Generic helper to add a record to all primary instances. @@ -531,10 +549,18 @@ impl ReconcilableRecord for ARecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "A" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::A + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { ARecordOp { ipv4_addresses: spec.ipv4_addresses.clone(), @@ -548,6 +574,10 @@ impl ReconcilableRecord for ARecord { fn get_ttl(spec: &Self::Spec) -> Option { spec.ttl } + + fn get_display_addresses(spec: &Self::Spec) -> Option { + Some(spec.ipv4_addresses.join(",")) + } } /// AAAA record operation wrapper. @@ -592,10 +622,18 @@ impl ReconcilableRecord for AAAARecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "AAAA" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::AAAA + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { AAAARecordOp { ipv6_addresses: spec.ipv6_addresses.clone(), @@ -609,6 +647,10 @@ impl ReconcilableRecord for AAAARecord { fn get_ttl(spec: &Self::Spec) -> Option { spec.ttl } + + fn get_display_addresses(spec: &Self::Spec) -> Option { + Some(spec.ipv6_addresses.join(",")) + } } /// CNAME record operation wrapper. @@ -646,10 +688,18 @@ impl ReconcilableRecord for CNAMERecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "CNAME" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::CNAME + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { CNAMERecordOp { target: spec.target.clone(), @@ -700,10 +750,18 @@ impl ReconcilableRecord for TXTRecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "TXT" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::TXT + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { TXTRecordOp { texts: spec.text.clone(), @@ -763,10 +821,18 @@ impl ReconcilableRecord for MXRecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "MX" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::MX + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { MXRecordOp { priority: spec.priority, @@ -825,10 +891,18 @@ impl ReconcilableRecord for NSRecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "NS" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::NS + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { NSRecordOp { nameserver: spec.nameserver.clone(), @@ -889,10 +963,18 @@ impl ReconcilableRecord for SRVRecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "SRV" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::SRV + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { SRVRecordOp { priority: spec.priority, @@ -957,10 +1039,18 @@ impl ReconcilableRecord for CAARecord { &self.spec } + fn get_status(&self) -> Option<&crate::crd::RecordStatus> { + self.status.as_ref() + } + fn record_type_name() -> &'static str { "CAA" } + fn record_type_hickory() -> hickory_proto::rr::RecordType { + hickory_proto::rr::RecordType::CAA + } + fn create_operation(spec: &Self::Spec) -> Self::Operation { CAARecordOp { flags: spec.flags, @@ -978,10 +1068,6 @@ impl ReconcilableRecord for CAARecord { } } -/// -/// # Errors -/// -/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. /// Generic record reconciliation function. /// /// This function handles reconciliation for all DNS record types that implement @@ -991,8 +1077,11 @@ impl ReconcilableRecord for CAARecord { /// The function: /// 1. Checks if the record is selected by a `DNSZone` (via status.zoneRef) /// 2. Looks up the `DNSZone` and gets primary instances -/// 3. Adds the record to BIND9 primaries using dynamic DNS updates -/// 4. Updates the record status based on success/failure +/// 3. Deletes the previously published name from BIND9 if `spec.name` changed +/// (rename detection via `status.publishedName`) +/// 4. Adds the record to BIND9 primaries using dynamic DNS updates +/// 5. Updates the record status based on success/failure; `status.addresses` +/// and `status.publishedName` are only set after a successful reconcile /// /// # Type Parameters /// @@ -1043,6 +1132,56 @@ where return Ok(()); // Record not selected or status already updated }; + // Handle renames: if the record was previously published under a different + // DNS name (status.publishedName), delete the old FQDN from the zone first. + // Otherwise the old name would remain orphaned in BIND9 forever. + if let Some(old_name) = detect_renamed_record(record.get_status(), T::get_record_name(spec)) { + info!( + "{} record {}/{} renamed from '{}' to '{}' - deleting old name from zone {}", + T::record_type_name(), + namespace, + name, + old_name, + T::get_record_name(spec), + rec_ctx.zone_ref.zone_name + ); + + if let Err(e) = delete_record_from_primaries( + &client, + &ctx.stores, + &rec_ctx.primary_refs, + &rec_ctx.zone_ref.zone_name, + &old_name, + T::record_type_hickory(), + true, // fail_on_error: do not publish the new name until the old one is gone + ) + .await + { + warn!( + "Failed to delete renamed {} record '{}' from zone {}: {}", + T::record_type_name(), + old_name, + rec_ctx.zone_ref.zone_name, + e + ); + update_record_status( + &client, + &record, + "Ready", + "False", + "ReconcileFailed", + &format!("Failed to delete renamed record '{old_name}' from zone: {e}"), + current_generation, + None, // record_hash + None, // last_updated + None, // addresses + None, // published_name (preserve old name so deletion is retried) + ) + .await?; + return Ok(()); + } + } + // Create type-specific operation from spec let record_op = T::create_operation(spec); @@ -1067,7 +1206,7 @@ where rec_ctx.primary_refs.len() ); - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] + // Update lastReconciledAt timestamp in DNSZone.status.records[] update_record_reconciled_timestamp( &client, &rec_ctx.zone_ref.namespace, @@ -1078,7 +1217,8 @@ where ) .await?; - // Update record status to Ready + // Update record status to Ready. Addresses (A/AAAA display field) and + // publishedName are only set after a successful, selected reconcile. update_record_status( &client, &record, @@ -1093,7 +1233,8 @@ where current_generation, Some(rec_ctx.current_hash), Some(chrono::Utc::now().to_rfc3339()), - None, // addresses + T::get_display_addresses(spec), + Some(T::get_record_name(spec).to_string()), ) .await?; } @@ -1116,6 +1257,7 @@ where None, // record_hash None, // last_updated None, // addresses + None, // published_name ) .await?; } @@ -1124,12 +1266,40 @@ where Ok(()) } +/// Detects whether a record was renamed since it was last published to BIND9. +/// +/// Compares the record's `status.publishedName` (the DNS name most recently +/// written to BIND9) against the current `spec.name`. +/// +/// # Arguments +/// +/// * `status` - The record's current status, if any +/// * `current_name` - The record name from the current spec +/// +/// # Returns +/// +/// * `Some(old_name)` - The record was renamed; `old_name` must be deleted from DNS +/// * `None` - No rename occurred (never published, or name unchanged) +pub(crate) fn detect_renamed_record( + status: Option<&crate::crd::RecordStatus>, + current_name: &str, +) -> Option { + let published = status?.published_name.as_deref()?; + if published == current_name { + return None; + } + Some(published.to_string()) +} + /// Reconciles an `ARecord` (IPv4 address) resource. /// /// This is a thin wrapper around the generic `reconcile_record()` function. /// It finds `DNSZones` that have selected this record via label selectors and /// creates/updates the record in BIND9 primaries for those zones using dynamic DNS updates. /// +/// `status.addresses` (comma-separated IPv4 addresses for kubectl output) is only +/// published after a successful, selected reconcile. +/// /// # Errors /// /// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. @@ -1137,30 +1307,7 @@ pub async fn reconcile_a_record( ctx: std::sync::Arc, record: ARecord, ) -> Result<()> { - // Reconcile the record - reconcile_record(ctx.clone(), record.clone()).await?; - - // Update status with comma-separated addresses for kubectl output - let client = ctx.client.clone(); - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - let api: Api = Api::namespaced(client.clone(), &namespace); - - // Format addresses as comma-separated list - let addresses = record.spec.ipv4_addresses.join(","); - - // Patch just the addresses field in status - let status_patch = serde_json::json!({ - "status": { - "addresses": addresses - } - }); - - api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) - .await - .context("Failed to update addresses in status")?; - - Ok(()) + reconcile_record(ctx, record).await } /// Reconciles a `TXTRecord` (text) resource. @@ -1176,93 +1323,7 @@ pub async fn reconcile_txt_record( ctx: std::sync::Arc, record: TXTRecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling TXTRecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "TXT", spec, bind9_instances_store).await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using generic helper - let record_op = TXTRecordOp { - texts: spec.text.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added TXT record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "TXTRecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("TXT record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add TXT record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) + reconcile_record(ctx, record).await } /// Reconciles an `AAAARecord` (IPv6 address) resource. @@ -1277,97 +1338,7 @@ pub async fn reconcile_aaaa_record( ctx: std::sync::Arc, record: AAAARecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling AAAARecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "AAAA", spec, bind9_instances_store) - .await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using generic helper - let record_op = AAAARecordOp { - ipv6_addresses: spec.ipv6_addresses.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added AAAA record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "AAAARecord", - &name, - &namespace, - ) - .await?; - - // Format addresses as comma-separated list for kubectl output - let addresses = record.spec.ipv6_addresses.join(","); - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("AAAA record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - Some(addresses), - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add AAAA record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) + reconcile_record(ctx, record).await } /// Reconciles a `CNAMERecord` \(canonical name alias\) resource. @@ -1379,204 +1350,28 @@ pub async fn reconcile_aaaa_record( /// # Errors /// /// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. -#[allow(clippy::too_many_lines)] pub async fn reconcile_cname_record( ctx: std::sync::Arc, record: CNAMERecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); + reconcile_record(ctx, record).await +} - info!("Reconciling CNAMERecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "CNAME", spec, bind9_instances_store) - .await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using instances - let record_op = CNAMERecordOp { - target: spec.target.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added CNAME record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "CNAMERecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("CNAME record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add CNAME record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) -} - -/// Reconciles an `MXRecord` (mail exchange) resource. -/// -/// Finds `DNSZones` that have selected this record via label selectors and creates/updates -/// the record in BIND9 primaries for those zones using dynamic DNS updates. -/// MX records specify mail servers for email delivery. -/// -/// # Errors -/// -/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. -#[allow(clippy::too_many_lines)] -pub async fn reconcile_mx_record( - ctx: std::sync::Arc, - record: MXRecord, -) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling MXRecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "MX", spec, bind9_instances_store).await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using instances - let record_op = MXRecordOp { - priority: spec.priority, - mail_server: spec.mail_server.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added MX record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "MXRecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("MX record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add MX record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) -} +/// Reconciles an `MXRecord` (mail exchange) resource. +/// +/// Finds `DNSZones` that have selected this record via label selectors and creates/updates +/// the record in BIND9 primaries for those zones using dynamic DNS updates. +/// MX records specify mail servers for email delivery. +/// +/// # Errors +/// +/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. +pub async fn reconcile_mx_record( + ctx: std::sync::Arc, + record: MXRecord, +) -> Result<()> { + reconcile_record(ctx, record).await +} /// Reconciles an `NSRecord` (nameserver delegation) resource. /// @@ -1587,98 +1382,11 @@ pub async fn reconcile_mx_record( /// # Errors /// /// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. -#[allow(clippy::too_many_lines)] pub async fn reconcile_ns_record( ctx: std::sync::Arc, record: NSRecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling NSRecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "NS", spec, bind9_instances_store).await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using instances - let record_op = NSRecordOp { - nameserver: spec.nameserver.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added NS record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "NSRecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("NS record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add NS record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) + reconcile_record(ctx, record).await } /// Reconciles an `SRVRecord` (service location) resource. @@ -1690,101 +1398,11 @@ pub async fn reconcile_ns_record( /// # Errors /// /// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. -#[allow(clippy::too_many_lines)] pub async fn reconcile_srv_record( ctx: std::sync::Arc, record: SRVRecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling SRVRecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "SRV", spec, bind9_instances_store).await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using instances - let record_op = SRVRecordOp { - priority: spec.priority, - weight: spec.weight, - port: spec.port, - target: spec.target.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added SRV record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "SRVRecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("SRV record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add SRV record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) + reconcile_record(ctx, record).await } /// Reconciles a `CAARecord` \(certificate authority authorization\) resource. @@ -1797,100 +1415,11 @@ pub async fn reconcile_srv_record( /// # Errors /// /// Returns an error if Kubernetes API operations fail or BIND9 record creation fails. -#[allow(clippy::too_many_lines)] pub async fn reconcile_caa_record( ctx: std::sync::Arc, record: CAARecord, ) -> Result<()> { - let client = ctx.client.clone(); - let bind9_instances_store = &ctx.stores.bind9_instances; - let namespace = record.namespace().unwrap_or_default(); - let name = record.name_any(); - - info!("Reconciling CAARecord: {}/{}", namespace, name); - - let spec = &record.spec; - let current_generation = record.metadata.generation; - - // Use generic helper to get zone and instances - let Some(rec_ctx) = - prepare_record_reconciliation(&client, &record, "CAA", spec, bind9_instances_store).await? - else { - return Ok(()); // Record not selected or status already updated - }; - - // Add record to BIND9 primaries using instances - let record_op = CAARecordOp { - flags: spec.flags, - tag: spec.tag.clone(), - value: spec.value.clone(), - }; - match add_record_to_instances_generic( - &client, - &ctx.stores, - &rec_ctx.primary_refs, - &rec_ctx.zone_ref.zone_name, - &spec.name, - spec.ttl, - record_op, - ) - .await - { - Ok(()) => { - info!( - "Successfully added CAA record {}.{} via {} primary instance(s)", - spec.name, - rec_ctx.zone_ref.zone_name, - rec_ctx.primary_refs.len() - ); - - // Update lastReconciledAt timestamp in DNSZone.status.selectedRecords[] - update_record_reconciled_timestamp( - &client, - &rec_ctx.zone_ref.namespace, - &rec_ctx.zone_ref.name, - "CAARecord", - &name, - &namespace, - ) - .await?; - - update_record_status( - &client, - &record, - "Ready", - "True", - "ReconcileSucceeded", - &format!("CAA record added to zone {}", rec_ctx.zone_ref.zone_name), - current_generation, - Some(rec_ctx.current_hash), - Some(chrono::Utc::now().to_rfc3339()), - None, // addresses - ) - .await?; - } - Err(e) => { - warn!( - "Failed to add CAA record {}.{}: {}", - spec.name, rec_ctx.zone_ref.zone_name, e - ); - update_record_status( - &client, - &record, - "Ready", - "False", - "ReconcileFailed", - &format!("Failed to add record to zone: {e}"), - current_generation, - None, // record_hash - None, // last_updated - None, // addresses - ) - .await?; - } - } - - Ok(()) + reconcile_record(ctx, record).await } /// Generic function to delete a DNS record from BIND9 primaries. @@ -1899,7 +1428,9 @@ pub async fn reconcile_caa_record( /// 1. Gets the zone reference from the record's status /// 2. Looks up the `DNSZone` to get instances /// 3. Filters to primary instances -/// 4. Deletes the record from all primaries +/// 4. Deletes the record from all primaries (best-effort), using +/// `status.publishedName` when present so renamed records delete the +/// name actually published to DNS, falling back to `spec.name` /// /// # Arguments /// @@ -1942,9 +1473,8 @@ where info!("Deleting {} record: {}/{}", record_type, namespace, name); // Extract status fields generically - let status = serde_json::to_value(record) - .ok() - .and_then(|v| v.get("status").cloned()); + let record_json = serde_json::to_value(record).ok(); + let status = record_json.as_ref().and_then(|v| v.get("status").cloned()); let zone_ref = status .as_ref() @@ -2013,46 +1543,126 @@ where return Ok(()); } - // Delete record from all primaries + // Determine the DNS name actually published to BIND9. Prefer + // status.publishedName (handles renames), then spec.name, then the + // resource name as a last resort. + let record_name_str = status + .as_ref() + .and_then(|s| s.get("publishedName")) + .and_then(|p| p.as_str()) + .map(ToString::to_string) + .or_else(|| { + record_json + .as_ref() + .and_then(|v| v.get("spec")) + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| name.clone()); + + // Delete record from all primaries (best-effort: finalizer removal must + // not be blocked by unreachable endpoints) + delete_record_from_primaries( + client, + stores, + &primary_refs, + &zone_ref.zone_name, + &record_name_str, + record_type_hickory, + false, // fail_on_error: allow Kubernetes deletion to proceed + ) + .await?; + + info!( + "Successfully deleted {} record {}/{} from {} primary instance(s)", + record_type, + namespace, + name, + primary_refs.len() + ); + + Ok(()) +} + +/// Deletes a DNS record (by name and type) from all given primary instances. +/// +/// Shared by the record finalizer (`delete_record`), the rename cleanup in +/// `reconcile_record`, and the `DNSZone` controller when a record is no longer +/// selected by the zone's `recordsFrom` selectors. +/// +/// # Arguments +/// +/// * `client` - Kubernetes API client +/// * `stores` - Context stores for creating `Bind9Manager` instances +/// * `primary_refs` - Primary instance references to delete the record from +/// * `zone_name` - DNS zone name (e.g., "example.com") +/// * `record_name` - Record name within the zone (e.g., "www") +/// * `record_type_hickory` - hickory-proto `RecordType` of the record +/// * `fail_on_error` - When `true`, a failed DNS deletion on any endpoint fails +/// the call (used when the record data must be gone before proceeding). +/// When `false`, failures are logged and skipped (best-effort finalizer cleanup). +/// +/// # Errors +/// +/// Returns an error if endpoint resolution fails, or if a DNS deletion fails +/// and `fail_on_error` is `true`. +pub(crate) async fn delete_record_from_primaries( + client: &Client, + stores: &crate::context::Stores, + primary_refs: &[crate::crd::InstanceReference], + zone_name: &str, + record_name: &str, + record_type_hickory: hickory_proto::rr::RecordType, + fail_on_error: bool, +) -> Result<()> { // Create a map of instance name -> namespace for quick lookup let instance_map: std::collections::HashMap = primary_refs .iter() .map(|inst| (inst.name.clone(), inst.namespace.clone())) .collect(); - let (_first_endpoint, total_endpoints) = - crate::reconcilers::dnszone::helpers::for_each_instance_endpoint( + // Collect per-endpoint failures ourselves: for_each_instance_endpoint only + // fails when ALL endpoints fail, but with fail_on_error we must also fail + // on PARTIAL failures (a record left on any endpoint is still an orphan). + // The closure always returns Ok so every endpoint is attempted. + let failures: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + + // Best-effort finalizer cleanup (fail_on_error=false) must not be blocked + // forever by an instance whose RNDC Secret is gone or that has zero ready + // endpoints - the DNS data there is unreachable anyway. Strict callers + // (fail_on_error=true) keep propagating those lookup failures. + let failure_policy = if fail_on_error { + crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::Strict + } else { + crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::SkipUnavailable + }; + + let (_first_endpoint, _total_endpoints) = + crate::reconcilers::dnszone::helpers::for_each_instance_endpoint_with_policy( client, - &primary_refs, + primary_refs, true, // with_rndc_key "dns-tcp", // Use DNS TCP port for dynamic updates + failure_policy, |pod_endpoint, instance_name, rndc_key| { - let zone_name = zone_ref.zone_name.clone(); - let record_name_str = if let Some(record_spec) = serde_json::to_value(record) - .ok() - .and_then(|v| v.get("spec").cloned()) - { - record_spec - .get("name") - .and_then(|n| n.as_str()) - .unwrap_or(&name) - .to_string() - } else { - name.clone() - }; + let zone_name = zone_name.to_string(); + let record_name_str = record_name.to_string(); let instance_namespace = instance_map .get(&instance_name) .expect("Instance should be in map") .clone(); + let failures = std::sync::Arc::clone(&failures); // Create Bind9Manager for this specific instance with deployment-aware auth - let zone_manager = stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace); + let zone_manager = + stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace); async move { let key_data = rndc_key.expect("RNDC key should be loaded"); - // Attempt to delete - if it fails, log warning but don't fail the deletion - if let Err(e) = zone_manager + let delete_result = zone_manager .delete_record( &zone_name, &record_name_str, @@ -2060,17 +1670,27 @@ where &pod_endpoint, &key_data, ) - .await - { - warn!( - "Failed to delete {} record {}.{} from endpoint {} (instance: {}): {}. Continuing with deletion anyway.", - record_type, record_name_str, zone_name, pod_endpoint, instance_name, e - ); - } else { - info!( - "Successfully deleted {} record {}.{} from endpoint {} (instance: {})", - record_type, record_name_str, zone_name, pod_endpoint, instance_name - ); + .await; + + match delete_result { + Ok(()) => { + info!( + "Successfully deleted {} record {}.{} from endpoint {} (instance: {})", + record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name + ); + } + Err(e) => { + warn!( + "Failed to delete {} record {}.{} from endpoint {} (instance: {}): {}", + record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name, e + ); + failures + .lock() + .expect("delete failures mutex should not be poisoned") + .push(format!( + "endpoint {pod_endpoint} (instance: {instance_name}): {e}" + )); + } } Ok(()) @@ -2079,15 +1699,51 @@ where ) .await?; - info!( - "Successfully deleted {} record {}/{} from {} primary endpoint(s)", - record_type, namespace, name, total_endpoints - ); + let failures = failures + .lock() + .expect("delete failures mutex should not be poisoned"); + + if fail_on_error && !failures.is_empty() { + return Err(anyhow::anyhow!( + "Failed to delete {} record {}.{} from {} endpoint(s): {}", + record_type_hickory, + record_name, + zone_name, + failures.len(), + failures.join("; ") + )); + } + + if !failures.is_empty() { + warn!( + "Failed to delete {} record {}.{} from {} endpoint(s); continuing anyway (best-effort)", + record_type_hickory, + record_name, + zone_name, + failures.len() + ); + } Ok(()) } -/// Update lastReconciledAt timestamp for a record in DNSZone.status.selectedRecords[]. +/// Builds the merge patch that updates `DNSZone.status.records[]`. +/// +/// The `DNSZoneStatus` field is named `records` on the wire (camelCase of +/// `pub records`). Using any other key (e.g., the old `selectedRecords`) is +/// silently pruned by the CRD structural schema, so timestamps never persist. +#[must_use] +pub(crate) fn build_records_timestamp_patch( + records: &[crate::crd::RecordReferenceWithTimestamp], +) -> serde_json::Value { + json!({ + "status": { + "records": records + } + }) +} + +/// Update lastReconciledAt timestamp for a record in `DNSZone.status.records[]`. /// /// This signals that the record has been successfully configured in BIND9. /// Future reconciliations will skip this record until the timestamp is reset. @@ -2105,7 +1761,6 @@ where /// /// Returns an error if: /// - `DNSZone` cannot be fetched from Kubernetes API -/// - Record is not found in zone's `selectedRecords[]` array /// - Status patch operation fails pub async fn update_record_reconciled_timestamp( client: &Client, @@ -2137,18 +1792,19 @@ pub async fn update_record_reconciled_timestamp( if !found { warn!( - "Record {} {}/{} not found in DNSZone {}/{} selectedRecords[] - cannot update timestamp", + "Record {} {}/{} not found in DNSZone {}/{} status.records[] - cannot update timestamp", record_kind, record_namespace, record_name, zone_namespace, zone_name ); return Ok(()); } - // Patch the status with updated timestamp - let status_patch = json!({ - "status": { - "selectedRecords": zone.status.as_ref().map(|s| &s.records) - } - }); + // Patch the status with updated timestamp (key MUST be `records` - see + // build_records_timestamp_patch) + let status_patch = zone + .status + .as_ref() + .map(|s| build_records_timestamp_patch(&s.records)) + .unwrap_or_else(|| build_records_timestamp_patch(&[])); api.patch_status( zone_name, diff --git a/src/reconcilers/records/status_helpers.rs b/src/reconcilers/records/status_helpers.rs index 9931c39d..69a2d8bc 100644 --- a/src/reconcilers/records/status_helpers.rs +++ b/src/reconcilers/records/status_helpers.rs @@ -69,6 +69,9 @@ where /// * `observed_generation` - Optional generation to set in status (defaults to record's current generation) /// * `record_hash` - Optional hash of the record spec for change detection /// * `last_updated` - Optional timestamp of last update +/// * `addresses` - Optional display addresses; `None` preserves any existing value +/// * `published_name` - DNS name just published to BIND9 (used for rename +/// detection); `None` preserves any existing value /// /// # Errors /// @@ -85,6 +88,7 @@ pub(super) async fn update_record_status( record_hash: Option, last_updated: Option, addresses: Option, + published_name: Option, ) -> Result<()> where T: Resource @@ -210,6 +214,15 @@ where .map(ToString::to_string) }); + // Use provided published name if available, otherwise preserve existing + let status_published_name = published_name.or_else(|| { + current_json + .get("status") + .and_then(|s| s.get("publishedName")) + .and_then(|p| p.as_str()) + .map(ToString::to_string) + }); + #[allow(deprecated)] // Maintain backward compatibility with deprecated zone field let record_status = RecordStatus { conditions: vec![condition], @@ -219,6 +232,7 @@ where record_hash, last_updated, addresses: status_addresses, // Set by A/AAAA record reconcilers or preserved from existing + published_name: status_published_name, // Set on success by reconcile_record or preserved }; let status_patch = json!({ diff --git a/src/reconcilers/records_tests.rs b/src/reconcilers/records_tests.rs index bef8beda..71ee4177 100644 --- a/src/reconcilers/records_tests.rs +++ b/src/reconcilers/records_tests.rs @@ -527,6 +527,7 @@ mod tests { record_hash: None, last_updated: None, addresses: None, + published_name: None, }; assert_eq!(status.conditions.len(), 1); @@ -562,6 +563,7 @@ mod tests { record_hash: None, last_updated: None, addresses: None, + published_name: None, }; assert_eq!(status.conditions.len(), 2); @@ -588,6 +590,7 @@ mod tests { record_hash: None, last_updated: None, addresses: None, + published_name: None, }; assert_eq!(status.conditions[0].status, "False"); @@ -1370,4 +1373,135 @@ mod tests { assert!(new_status.records.is_empty()); } } + + // ======================================================================== + // Timestamp Patch Tests (DNSZone.status.records[] key correctness) + // ======================================================================== + + mod timestamp_patch_tests { + use crate::constants::API_GROUP_VERSION; + use crate::crd::{DNSZoneStatus, RecordReferenceWithTimestamp}; + use crate::reconcilers::records::build_records_timestamp_patch; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; + + fn sample_record_ref(name: &str) -> RecordReferenceWithTimestamp { + RecordReferenceWithTimestamp { + api_version: API_GROUP_VERSION.to_string(), + kind: "ARecord".to_string(), + name: name.to_string(), + namespace: "bindy-system".to_string(), + record_name: Some("www".to_string()), + last_reconciled_at: "2026-01-01T00:00:00Z" + .parse::() + .ok() + .map(Time), + } + } + + #[test] + fn test_build_records_timestamp_patch_uses_records_key() { + let records = vec![sample_record_ref("web-a-record")]; + + let patch = build_records_timestamp_patch(&records); + + // The DNSZoneStatus field serializes as `records` - patching any other + // key (e.g. `selectedRecords`) is silently pruned by the structural schema. + let status = patch + .get("status") + .expect("patch must have a status object"); + assert!( + status.get("records").is_some(), + "patch must use the `records` key: {patch}" + ); + assert!( + status.get("selectedRecords").is_none(), + "patch must NOT use the pruned `selectedRecords` key: {patch}" + ); + } + + #[test] + fn test_build_records_timestamp_patch_roundtrips_into_dnszone_status() { + let records = vec![sample_record_ref("web-a-record"), { + let mut second = sample_record_ref("api-a-record"); + second.last_reconciled_at = None; + second + }]; + + let patch = build_records_timestamp_patch(&records); + + // The patch body must deserialize back into DNSZoneStatus without + // losing any record entries (i.e. no unknown fields to prune). + let status: DNSZoneStatus = + serde_json::from_value(patch.get("status").expect("status present").clone()) + .expect("patch status must deserialize into DNSZoneStatus"); + assert_eq!(status.records.len(), 2); + assert!(status.records[0].last_reconciled_at.is_some()); + assert!(status.records[1].last_reconciled_at.is_none()); + } + } + + // ======================================================================== + // Rename Detection Tests (status.publishedName tracking) + // ======================================================================== + + mod rename_detection_tests { + use crate::crd::RecordStatus; + use crate::reconcilers::records::detect_renamed_record; + + fn status_with_published_name(published_name: Option<&str>) -> RecordStatus { + RecordStatus { + published_name: published_name.map(ToString::to_string), + ..RecordStatus::default() + } + } + + #[test] + fn test_detect_renamed_record_no_status() { + // A record with no status was never published - nothing to clean up + assert_eq!(detect_renamed_record(None, "www"), None); + } + + #[test] + fn test_detect_renamed_record_no_published_name() { + // A record without publishedName was never published under another name + let status = status_with_published_name(None); + assert_eq!(detect_renamed_record(Some(&status), "www"), None); + } + + #[test] + fn test_detect_renamed_record_same_name() { + // spec.name matches the published name - no rename occurred + let status = status_with_published_name(Some("www")); + assert_eq!(detect_renamed_record(Some(&status), "www"), None); + } + + #[test] + fn test_detect_renamed_record_changed_name() { + // spec.name changed - the old published name must be deleted from DNS + let status = status_with_published_name(Some("www")); + assert_eq!( + detect_renamed_record(Some(&status), "web"), + Some("www".to_string()) + ); + } + + #[test] + fn test_record_status_published_name_serialized_when_set() { + let status = status_with_published_name(Some("www")); + let json = serde_json::to_value(&status).expect("status must serialize"); + assert_eq!( + json.get("publishedName").and_then(|v| v.as_str()), + Some("www") + ); + } + + #[test] + fn test_record_status_published_name_omitted_when_none() { + // publishedName must be omitted (not null) when unset so that + // merge patches preserve any existing value. + let status = status_with_published_name(None); + let json = serde_json::to_value(&status).expect("status must serialize"); + assert!(json.get("publishedName").is_none()); + } + } } diff --git a/src/record_wrappers_tests.rs b/src/record_wrappers_tests.rs index 8691655f..c2bc440b 100644 --- a/src/record_wrappers_tests.rs +++ b/src/record_wrappers_tests.rs @@ -30,6 +30,7 @@ mod tests { zone_ref: None, record_hash: Some("hash123".to_string()), addresses: None, + published_name: None, } } diff --git a/templates/named.conf.options.tmpl b/templates/named.conf.options.tmpl index d3a1c60b..17b552bf 100644 --- a/templates/named.conf.options.tmpl +++ b/templates/named.conf.options.tmpl @@ -8,10 +8,12 @@ options { // bindcar 0.7.0 migration: named listens on the standard DNS port 53 // (bound as non-root via the NET_BIND_SERVICE capability). Secondary-zone // `primaries` entries are plain IPs, so zone transfers use this port. - listen-on port 53 { any; }; - listen-on-v6 port 53 { any; }; + // Defaults to `any` unless listenOn / listenOnV6 are set in the CRD. + {{LISTEN_ON}} + {{LISTEN_ON_V6}} allow-new-zones yes; {{RECURSION}} + {{FORWARDERS}} {{ALLOW_QUERY}} {{ALLOW_TRANSFER}} {{DNSSEC_VALIDATE}}