Files
jakman85andClaude Opus 5 9137bbd0c3 feat(dns): INVERT to operator's simpler layout — dev to subdomain, prod to apex
*** DO NOT MERGE TO main WHILE THE OPERATOR IS AWAY. MERGING IS THE MIGRATION. ***

Operator simplified the architecture, inverting c8c1d29:
  dev  -> *.dev.home.keithjackson.dev        (was the apex)
  prod -> *.home.keithjackson.dev            (the apex; no prod. subdomain, no CNAME layer)

WHY MERGING IS DANGEROUS, and this is the finding that matters most here:
clusters/dev/sync.yaml self-reconciles ./clusters/dev from `main` every 10 minutes. Merging
these files EXECUTES the cutover on the live dev cluster, unattended. And because the same
commit moves dev's GitRepository url to gitea.dev.home.keithjackson.dev, dev STRANDS — it
stops reconciling and cannot be fixed by pushing, because the broken thing IS the path Flux
reads pushes through. Banners added at the top of clusters/dev/kustomization.yaml and beside
the url itself. That url cannot be tokenized: the root Kustomization declares no
substituteFrom, so `gitea.${BASE_DOMAIN}` would ship the literal token.

Verified dev is currently safe: this work is NOT on origin/main, dev is reconciling
main@4809199, and its live BASE_DOMAIN is still home.keithjackson.dev. Deliberately NOT
pushed to the bootstrap Gitea either — prod reconciles from there, and prod on `prod.home.`
cannot collide with dev on the apex, so that is the safer interim for prod.

VALUES
                BASE_DOMAIN                  TXT_OWNER_ID   DOMAIN_FILTER
  base          dev.home.keithjackson.dev    sentinel       keithjackson.dev
  dev           dev.home.keithjackson.dev    dev            keithjackson.dev
  prod          home.keithjackson.dev        prod           keithjackson.dev
Ansible seam inverted too: role default k3s_base_domain = dev's subdomain, group_vars/k3s_prod
overrides to the apex.

dev's txtOwnerId moves `default` -> `dev`. The old value was FORCED (its live apex records
were already registered under `default`, so renaming would have orphaned them). On a new
suffix dev publishes a brand-new record set with no prior registry, so the id is chosen on
merits — and `default` has none: it is what you get by FORGETTING the flag, which is exactly
how both clusters came to share it and delete each other's records. Operationally it also
means dev's old apex records stay owned by a now-unclaimed id, so nothing deletes them
implicitly and vacating the apex becomes an explicit sequenceable step rather than a race
between two reconcile loops. PROD MUST NEVER BE GIVEN `default`: prod inherits the apex
NAMES, so `default` is the one value that would let it adopt and policy:sync-delete dev's
still-live apex records — the same incident with the roles swapped.

DOMAIN_FILTER stays the ZONE for both, with F-DNS1 written into all three files as a
do-not-retry: a subdomain filter drops the parent zone and publishes NOTHING.

VERIFICATION — the property changed, and so did the method. "Dev renders byte-identically" no
longer applies because dev is deliberately migrating. Instead: apply the INVERSE substitution
to the new render and demand byte-identity with the baseline. 145 changed line pairs, every
one classified: 143 hostname-move, 2 declared owner-id, ZERO unexplained, ZERO bare-apex
remnants. cilium and velero show 0 changes and provably contain no hostname, so they are not
blind spots. Prod: 0 occurrences of prod.home., 0 of dev.home., 143 apex names. Certs: dev
*.dev.home.keithjackson.dev, prod *.home.keithjackson.dev.

THREE positive controls, all fire — including one that proves the two checks are not
redundant: injecting a single un-moved hostname SURVIVES the inverse-substitution check and is
caught only by the independent bare-apex scan. That is why both exist.

Ansible probe (connection: local, zero SSH): dev nodes -> keycloak.dev.home..., prod nodes ->
keycloak.home.... Used roles: + --tags probe rather than include_vars, because include_vars
outranks group_vars and would have FALSELY shown prod inheriting dev's value.

Gates all PASS: l0, l1, render-golden (10 goldens), lint, substitution (254 assertions),
render.py --selftest, spec-immutability. Catalog ServiceDefinitions edited, never rendered apps/.

RUNBOOK rewritten in §10 + §10a with the ordering that avoids stranding: D0 snapshot escape
routes -> D1 break the automatic trigger -> D2 publish and PROVE gitea.dev.home + the new
wildcard cert WHILE dev still serves the apex -> D3 merge -> D4 release reconciliation
deliberately -> D5 Keycloak issuer + ~12 clients (headlamp's is SOPS, by hand) -> D6
one-server-at-a-time k3s restart + SSO re-issue (service restart only, NO reboots) -> D7
retire old apex records -> D8 verify the apex is empty AGAINST CLOUDFLARE, not pfSense —
the gate on everything after. Then P1 unsuspend prod.

ACME: prod's wildcard is EXACTLY the name set dev holds today, so prod's orders share dev's
5-per-week duplicate-certificate bucket. Order after D8, delete dev's old Certificate rather
than let it renew, rehearse on staging.

The 6 L2 chainsaw tests hardcoding the apex are enumerated but deliberately NOT edited — while
dev is live on the apex they are correct; they need per-environment parameterisation.

Serves: R-ENV-02, R-DNS-01/02/03, R-TLS-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-04 23:08:50 -05:00
..

clusters/

Flux entrypoints and the environment-overlay model (R-ENV-02, R-GITOPS-01/02/03).

Governing spec: docs/05-gitops-and-delivery.md, docs/02-environments-and-provisioning.md, ADR-0004 (Flux).

Phase 0 scaffolding only. These are structurally-correct stubs. No cluster exists — nothing here reconciles until WS-INFRA delivers a cluster and bootstraps Flux (roadmap Phase 1).

Layout — base + overlay (never fork the base)

clusters/
  base/                 shared Flux objects (one authoring, both environments)
    flux-system.yaml    GitRepository — the reconciliation source (bootstrap → Gitea)
    platform.yaml       Flux Kustomization → ./platform   (reconciles first)
    apps-wave-0.yaml    Flux Kustomization → ./apps/wave-0 (dependsOn platform-config)
    apps-wave-1.yaml    Flux Kustomization → ./apps/wave-1 (dependsOn apps-wave-0)
    apps-wave-2.yaml    Flux Kustomization → ./apps/wave-N (dependsOn apps-wave-N-1)
    cluster-vars.yaml   ConfigMap of overlay knobs (BASE defaults)
    kustomization.yaml
  dev/                  ACTIVE build target — patches cluster-vars with dev values only
    kustomization.yaml
  prod/                 DESIGN-ONLY until roadmap Phase 7 — patches cluster-vars with prod values only
    kustomization.yaml

How the overlay model satisfies R-ENV-02

The base authors the reconciliation graph once. platform.yaml and the app-wave Kustomizations (apps-wave-0/1/2.yaml — the single apps.yaml was retired; apps-vms.yaml deliberately omits it, see below) declare postBuild.substituteFrom the cluster-vars ConfigMap. Platform and app manifests use ${TOKEN} placeholders for every value that legitimately differs between environments (issuer, replica counts, storage sizes, retention, node placement).

clusters/dev and clusters/prod differ ONLY by the values they patch into cluster-vars — they add no resources of their own and never copy/fork a base manifest. Promotion to prod (docs/02) is a change to the prod overlay's variables, nothing else.

LAN addressing is a knob, not a literal (fixed 2026-08-31)

Until 2026-08-31 the base manifests hardcoded the dev LAN: the k3s API host Cilium dials, the Cilium LB IP pool range, the pinned crafty/unifi L4 VIPs and the off-cluster S3 backup endpoint. That broke R-ENV-02 outright — clusters/prod would have rendered dev addresses (192.168.1.x) onto the prod LAN (192.168.2.x). They are now the cluster-vars "LAN addressing" block (K3S_API_HOST, CILIUM_LB_POOL_START/_STOP, UNIFI_L4_VIP, CRAFTY_L4_VIP, S3_BACKUP_ENDPOINT, alongside the pre-existing GATEWAY_VIP), set explicitly by BOTH overlays.

Two rules when touching that block:

  1. Substitution coverage. A ${TOKEN} only resolves in a path whose Flux Kustomization declares postBuild.substituteFrom: cluster-vars; anywhere else the literal string ${TOKEN} ships to the cluster. apps-vms was the one path without it and now has it — which means every shell ${...} in a VM manifest's cloud-init must be escaped $${...} (see clusters/base/apps-vms.yaml for the full envsubst escape contract).
  2. GATEWAY_VIP must be inside CILIUM_LB_POOL_START..STOP, and in practice equal to START: the shared Gateway pins no address, so Cilium LB-IPAM gives it the pool's first free IP while split-horizon CoreDNS answers with ${GATEWAY_VIP}. A VIP outside the pool blackholes in-cluster hairpin traffic while the Gateway still looks healthy.

Values the operator has not decided are TBD-PROD-* placeholders, not guesses: prod inherits the DEV base defaults if an overlay op is simply omitted, so an invalid value that fails loudly is strictly safer than a plausible one that quietly comes up on the wrong network.

How Flux is pointed at an environment

flux bootstrap (run by WS-INFRA in Phase 1) targets one environment's path per cluster:

# dev cluster
flux bootstrap ... --path=clusters/dev
# prod cluster (Phase 7 only — design-only until then)
flux bootstrap ... --path=clusters/prod

Bootstrap materializes the flux-system Kustomization that renders the chosen overlay, which pulls in base/ and applies the env-scoped cluster-vars.

Reconciliation order

GitRepository (flux-system)
        │
        ▼
Kustomization: platform  (./platform, substituteFrom cluster-vars)
        │  healthy
        ▼
Kustomization: apps-wave-0  (./apps/wave-0, dependsOn platform-config, substituteFrom cluster-vars)
        │  healthy
        ▼
Kustomization: apps-wave-1  (./apps/wave-1, dependsOn apps-wave-0)
        │  healthy
        ▼
Kustomization: apps-wave-N  (./apps/wave-N, dependsOn apps-wave-N-1)

Wave 0 dependsOn platform-config (transitively platform) so CNI/storage/TLS/DNS/gateway exist before any workload reconciles. The app tier is split into dependency-ordered WAVES (catalog/render.py --emit-waves): each apps-wave-<N> health-gates (wait:true) ONLY its own wave, so a crashloop in a later wave can no longer stall an earlier wave or block new revisions. See DESIGN-waves.md for the wave table + cutover runbook.

TODOs (later phases — do not action in Phase 0)

  • Source cutover (WS-GITOPS, Phase 6): base/flux-system.yaml currently points at the bootstrap source. After Gitea is onboarded, repoint spec.url to gitea.home.keithjackson.dev and keep an off-cluster mirror (spec docs/05 "Source-of-truth", CONTRIBUTING.md).
  • SOPS decryption (WS-SEC, Phase 1): add spec.decryption.provider: sops (age secret name only, never plaintext) to the platform/apps Kustomizations once the age key is bootstrapped (ADR-0007).
  • Split ordered Flux Kustomizations if intra-platform ordering needs to be explicit (e.g. a separate cilium/longhorn Kustomization others dependsOn).