--- # 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. # # THE TWO GUARDS THAT ACTUALLY PREVENT A RECURRENCE — note that neither is domainFilters: # 1. txtOwnerId — per-environment identity; a cluster only ever touches records it owns. # 2. DISJOINT HOSTNAME SETS — the two environments serve different suffixes, so they never # describe the same record in the first place. dev = *.dev.home.keithjackson.dev, # prod = *.home.keithjackson.dev (prod IS the apex; operator decision 2026-09-04). # --------------------------------------------------------------------------------------------- # Which names this instance may manage at all. BOTH ENVIRONMENTS RENDER THE CLOUDFLARE **ZONE** # `keithjackson.dev` HERE, and this is NOT an oversight to be tightened. # # FINDING F-DNS1 (DECISIONS-LOG 2026-08-17): the Cloudflare provider uses this value for ZONE # SELECTION as well as record matching, so a filter that is a STRICT SUBDOMAIN of the hosted zone # matches NO zone — external-dns generates the record and then skips it with "no hosted zone # matching record DNS Name", publishing NOTHING. This has been "improved" and reverted more than # once (an agent narrowed prod to prod.home.keithjackson.dev on 2026-09-04; overridden in # review). It is not defence in depth; it is a total outage of DNS publication. Isolation comes # from the two guards above, which a domain filter cannot provide anyway — both environments live # in the ONE zone regardless of what this says. 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 `dev`, prod renders `prod`, and the BASE default is a # non-colliding sentinel so a new environment that forgets the knob owns nothing and can delete # nothing. NOTHING renders `default` any more (the external-dns chart default, and the value both # clusters silently shared): dev held it only because its live apex records were registered under # it, and dev's move to *.dev.home.keithjackson.dev retired that constraint along with those # records. Prod must NOT be given `default` even though it inherits the apex NAMES — that would # hand prod ownership of dev's old apex records and let `policy: sync` below delete them. 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.