OPERATOR DECISION 2026-09-06: "use the VIP as intended — a virtual address to reach
the cluster and provide fault tolerant HA. Without a change it will not work in HA."
THE DEFECT. Cilium's kube-proxy replacement dialled a SINGLE NODE. Re-confirmed live
before changing anything: all three prod cilium-agent pods carried
KUBERNETES_SERVICE_HOST=192.168.2.10 — k3s-prod-1's own address — while the kube-vip
VIP 192.168.2.9 was Running on all three nodes with a held lease. Lose prod-1 and the
CNI's control-plane path is dead on an "HA" cluster. clusters/prod's own comment said
the value must be the VIP and NOT .10, then called it "Not yet decided" — I treated
that stale comment as an open question when the evidence already settled it.
SPLIT, NOT REPOINT, because k3s_api_host had two consumers with opposite needs:
k3s_api_host unchanged — the FOUNDER's IP: the cold-bootstrap join
target and a tls-san. infra/tofu/outputs.tf GENERATES this
name, so renaming would ripple into tofu and the runbooks.
k3s_apiserver_endpoint NEW = {{ kube_vip_address }} — the HA address; what Cilium
consumes. Derived, not restated, so an environment overrides
ONE line and the VIP, the SAN and the CNI endpoint move
together. Prod needed no new override.
THE SECOND HAZARD, WHICH MY BRIEF DID NOT NAME and the agent found: pointing Cilium at
the VIP breaks a COLD bootstrap for the same reason the join would — site.yml ran
node-baseline -> k3s -> cilium, so Cilium would install against an address nothing
announced yet. That is the 2026-09-01 deadlock shape. Fixed by SEQUENCE, not value:
site.yml now imports kube-vip.yml BETWEEN k3s and cilium. It cannot go earlier
(kube-vip is a k3s auto-deploy manifest, so it needs a cluster) and it works there
because the DaemonSet is hostNetwork with KUBERNETES_SERVICE_HOST=127.0.0.1 — needing
neither CNI nor kube-proxy. roles/kube_vip gained a read-only `wait_for` on the VIP so
a cold run stops THERE with a named cause instead of surfacing as unreachable agents.
Also fixed a pre-existing bug that only mattered once this play joined site.yml: the
role's read-only interface probe ran without `check_mode: false`, so under --check it
was skipped and the assert fired with a misleading "set kube_vip_interface explicitly".
VERIFIED BY ME, not just reported:
renders dev K3S_API_HOST=192.168.1.49 prod=192.168.2.9 (each environment's VIP)
cilium template consumes k3s_apiserver_endpoint, not k3s_api_host
cold join still `--server https://{{ k3s_api_host }}:6443` — the founder
order site.yml: node-baseline -> k3s -> kube-vip -> cilium -> verify -> flux
gate full CI gate PASS
Agent-side, additionally: in-memory render of cilium-values.yaml.j2 per host showing
k8sServiceHost=the VIP with the join still on the founder; k3s_tls_sans renders
['.10','.9','127.0.0.1','.11','.12'] so NO cert reissue and NO k3s restart is involved
— .9 already answers under full CA verification; and --check --diff of kube-vip.yml
against prod reported 6 ok / 0 changed, i.e. the role edits change nothing about the
kube-vip prod already runs.
ha-control-plane-as-code 92 -> 112 assertions, turning two escalations into machine
checks: the Cilium template must use the endpoint var; the endpoint must derive from
kube_vip_address and k3s_api_host must not; per environment K3S_API_HOST must equal
that environment's VIP and be none of its node IPs (the [parity] contract, now
machine-checked rather than a comment); site.yml must order k3s -> kube-vip -> cilium.
Every new assertion mutation-tested and bites. One honest negative recorded: reverting
only the BASE default is not caught, because both overlays patch the key — the check
reads effective per-environment values, so the base default is unguarded.
APPLY ORDER, and this is the operator's step — nothing was applied:
PROD FIRST, and it is the low-risk direction (prod is the broken one). Push and let
Flux reconcile platform/cilium: the value change rolls the cilium-agent DaemonSet
ONE NODE AT A TIME (maxUnavailable: 1 — the knob that bounded the 2026-09-01
incident), it will not proceed past an unready pod, existing datapath survives an
agent restart, and .9 is already live before the change lands.
DEV SEPARATELY, operator present. Same mechanics but live dev agents dial
192.168.1.50, and dev is mid-DNS-migration on dev-live. Do not fold it into that.
ANSIBLE: nothing to run for either live cluster; the Ansible half matters only for a
cold build. Remove the now-redundant standalone kube-vip.yml step from any runbook.
NEW NAMED GAP, unwritten: the L2 test cannot assert the running agents' live
KUBERNETES_SERVICE_HOST, which is the drift the L0 check cannot see. Recorded in that
test's gap list.
Refs R-PLAT-01, R-NET-01, R-ENV-02.
Co-Authored-By: Claude Opus 5 <[email protected]>
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:
- Substitution coverage. A
${TOKEN}only resolves in a path whose Flux Kustomization declarespostBuild.substituteFrom: cluster-vars; anywhere else the literal string${TOKEN}ships to the cluster.apps-vmswas the one path without it and now has it — which means every shell${...}in a VM manifest's cloud-init must be escaped$${...}(seeclusters/base/apps-vms.yamlfor the full envsubst escape contract). GATEWAY_VIPmust be insideCILIUM_LB_POOL_START..STOP, and in practice equal to START: thesharedGateway 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)
Second-order CRDs need a THIRD pass (platform-config-cdi, added 2026-09-05)
The controllers/configs split (platform → platform-config) assumes a CRD is registered by the
controller install. CDI breaks that: cdi-operator.yaml registers only cdis.cdi.kubevirt.io,
and cdi-operator creates storageprofiles / datavolumes / datasources only after it has
reconciled the CDI CR. Since kustomize-controller server-side dry-runs every object in a pass
before applying any of them, having the CDI CR and the CDI StorageProfiles in one pass was a
deadlock, not a race: unknown kind → pass aborts → CR never applied → CRD never created →
platform-config can never converge → every tier below it blocked. Invisible on dev (its CDI CRDs
have existed for months); it cost 12h on prod's cold start.
So there is a third tier, platform-config-cdi (./platform-config-cdi, dependsOn: platform-config), and a rule:
A CR belongs in
platform-config-cdi, notplatform-config, when the CRD it needs is created by a controller at runtime rather than by a manifest in git.
apps-vms dependsOn it as well as platform-config, because CDI reads
StorageProfile/longhorn-vm at DataVolume provisioning time — a VM disk provisioned before that
profile exists is stamped RWO/Filesystem and is permanently not live-migratable.
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.yamlcurrently points at the bootstrap source. After Gitea is onboarded, repointspec.urltogitea.home.keithjackson.devand keep an off-cluster mirror (specdocs/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/longhornKustomization othersdependsOn).