--- # ExternalDNS — publishes public DNS records for `public` services automatically (docs/09, R-DNS-02). # Watches Gateway/HTTPRoute (+Service); manages records in Cloudflare. Phase 0 STUB. apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: external-dns namespace: external-dns spec: interval: 30m chart: spec: chart: external-dns version: ">=1.21.0 <1.22.0" # k8s-1.36 catch-up (app v0.21; policy already set). Renovate maintains sourceRef: kind: HelmRepository name: external-dns namespace: external-dns install: remediation: retries: 3 upgrade: remediation: retries: 3 values: provider: name: cloudflare # Watch Gateway API routes (Cilium Gateway) + Services. docs/09: records rendered from intent. sources: - gateway-httproute - service # --------------------------------------------------------------------------------------------- # CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below. # # On 2026-09-04 prod's external-dns DELETED EVERY ONE of dev's public records. Both clusters ran # `policy: sync` against the SAME Cloudflare zone, served the SAME hostnames, and — because # NEITHER set txtOwnerId — both inherited the chart default owner id `default`. Prod therefore # read dev's TXT registry entries as its own, found no local resource claiming those names, and # garbage-collected them. `policy: sync` is correct and stays; what was missing was identity. # Full write-up: infra/PROD-PROVISIONING.md §10. # # TWO INDEPENDENT GUARDS NOW, either of which alone prevents a recurrence: # 1. txtOwnerId — per-environment identity; a cluster only ever touches records it owns. # 2. domainFilters — per-environment name scope; prod cannot even see names outside its # own subtree. Defence in depth, added after the incident. # --------------------------------------------------------------------------------------------- # Which names this instance may manage at all. Cloudflare-provider caveat, empirically hit # 2026-08: this value is used for ZONE selection as well as record matching, and a filter that is # a strict subdomain of the zone dropped the parent zone entirely ("no hosted zone matching record # DNS Name") — so DEV keeps the zone apex `keithjackson.dev`, which is also what dev serves the # apex of. PROD narrows to `prod.home.keithjackson.dev`. If prod's first reconcile logs no # matching zone, that caveat still applies: prod creates NOTHING (fail-closed, never destructive) # and the fallback is to widen prod's EXTERNALDNS_DOMAIN_FILTER back to the zone apex, leaving # txtOwnerId as the sole guard. See clusters/base/cluster-vars.yaml. domainFilters: - "${EXTERNALDNS_DOMAIN_FILTER}" # The TXT-registry owner id. MUST be distinct per environment — this is the knob whose absence # caused the incident. Dev renders `default` (its live records are already registered under that # owner; changing it would orphan them and duplicate every record). Prod renders `prod`. txtOwnerId: "${EXTERNALDNS_TXT_OWNER_ID}" policy: sync # create + update + delete records as services come and go — but ONLY # records this owner id owns AND this domainFilter admits (see above). # Cloudflare API token: SOPS-encrypted secret, referenced BY NAME only (OQ-4, ADR-0007). env: - name: CF_API_TOKEN valueFrom: secretKeyRef: name: cloudflare-api-token key: api-token # R-DNS-02 PROVEN via the `service` source: the conformance/l2-behavioral/dns-exposure test # creates a LoadBalancer Service with an external-dns hostname and asserts the record auto-appears # in public DNS (then teardown + policy=sync removes it). gateway-httproute source wiring is the # same provider/zone path; a route-based test can follow when app routes exist.