Files
jakman85andClaude Opus 5 3f980d2a17 feat(env): tokenize hardcoded dev LAN addresses into cluster-vars (R-ENV-02)
R-ENV-02 promises dev and prod differ ONLY by scoped cluster-vars overrides. In fact
dev LAN addresses were hardcoded into base manifests, so clusters/prod would have come
up ANNOUNCING DEV ADDRESSES (192.168.1.x) on the prod LAN (192.168.2.x).

New cluster-vars knobs (base default = dev literal, per the file's existing convention):
  K3S_API_HOST          platform/cilium/helmrelease.yaml (+ the values.yaml mirror)
  CILIUM_LB_POOL_START  platform-config/cilium/l2-ip-pool.yaml
  CILIUM_LB_POOL_STOP   "
  UNIFI_L4_VIP          apps/vms/unifi/50-service-l4.yaml
  CRAFTY_L4_VIP         platform/crafty/service-l4.yaml
  S3_BACKUP_ENDPOINT    platform/keycloak/postgres-cluster.yaml, apps/wave-0/{nextcloud,
                        semaphore}-db.yaml, and platform/velero/helmrelease.yaml

Also fixes GATEWAY_VIP's base default, which was still the RETIRED libvirt address
10.73.0.240, and adds the GATEWAY_VIP patch that clusters/prod was missing entirely.

clusters/base/apps-vms.yaml gains postBuild.substituteFrom (it was the one consuming
Kustomization without it, so a ${TOKEN} there would have shipped as a literal string).
The envsubst contract is documented there: ${VAR} substitutes and escapes as $${VAR};
$VAR and $(cmd) are left alone; ${ARR[0]} hard-errors. The three UOS_URL expansions in
the unifi cloud-init are escaped accordingly.

PROD ADDRESS PLAN (operator, 2026-08-31):
  nodes .10/.11/.12 · control-plane VIP .9 (Ansible-side) · GATEWAY_VIP .20
  Cilium LB pool .20-.30

CONTAINMENT INVARIANT, now documented at the pool and in the prod overlay: GATEWAY_VIP
MUST equal CILIUM_LB_POOL_START. The shared Gateway pins no address, so Cilium LB-IPAM
gives it the first free address of a matching pool block. An initial prod proposal put
.20 outside a .21-.30 pool; the Gateway would have come up on .21 while split-horizon
coredns-custom kept answering *.home.keithjackson.dev with .20 — an address nothing
announces — blackholing in-cluster OIDC hairpin traffic while reporting healthy. The
pool was widened to .20-.30. The control-plane VIP (.9) stays outside the pool so
kube-vip and LB-IPAM can never contend.

Values prod still cannot know stay INVALID `TBD-PROD-*` placeholders on purpose: base
defaults are dev values, so an omitted patch means prod silently inherits a dev address
— far worse than a loud failed apply.

DEV IS BYTE-IDENTICAL. Verified by kustomize build over 13 paths before/after, then
flux envsubst with the dev cluster-vars values applied to both sides: zero drift. The
only deltas are the intended additive keys in the overlay ConfigMap itself. No
unresolved ${TOKEN} survives. render-golden green (10 goldens); yamllint clean.

Serves: R-ENV-02. Task: T-7.1 (prerequisite). Gate: MERGE GATE: PASS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-31 22:43:36 -05:00

117 lines
6.1 KiB
Markdown

# clusters/
Flux entrypoints and the environment-overlay model (**R-ENV-02**, **R-GITOPS-01/02/03**).
Governing spec: [`docs/05-gitops-and-delivery.md`](../../home-network-k3s/docs/05-gitops-and-delivery.md),
[`docs/02-environments-and-provisioning.md`](../../home-network-k3s/docs/02-environments-and-provisioning.md),
[ADR-0004 (Flux)](../../home-network-k3s/decisions/adr-0004-gitops-flux.md).
> 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`](../../home-network-k3s/docs/02-environments-and-provisioning.md)) 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](../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](../../home-network-k3s/decisions/adr-0007-secrets-sops-age.md)).
- **Split ordered Flux Kustomizations** if intra-platform ordering needs to be explicit (e.g. a
separate `cilium`/`longhorn` Kustomization others `dependsOn`).
</content>