Lets the operator's Android / Linux / Windows / macOS devices dial in from anywhere and behave like LAN machines: full reach to 192.168.2.0/24 plus internet egress out the house WAN. Opposite direction to platform/vpn-egress, which sends pods OUT through PIA; the two share nothing but the word "VPN". WireGuard because it is the only option with a first-party client on all four named platforms, and because its handshake MAC is keyed with the responder's own public key: an unauthenticated packet gets NO reply, so udp/51820 is indistinguishable from a closed port. That, not "it is encrypted", is what makes an unfiltered WAN port defensible, and why there is no source allowlist. hostNetwork: true is REQUIRED, not convenient. A pod-networked server must SNAT clients to its own pod IP, so every remote client would carry that pod's Cilium identity -- and a pod-sourced request to the shared Gateway VIP returns 403 from Envoy for ALL 16 hostnames (measured 2026-10-04), because Cilium's Gateway preserves the original source identity and enforces the destination's ingress policy. The obvious build would have shipped a healthy-looking tunnel on which every cluster web UI was 403 and *.home.keithjackson.dev did not even resolve. In the host netns traffic is SNATed to the node and arrives as host/remote-node, exactly like a LAN client. It also sidesteps the unsafe ip_forward sysctl, the L2-announcement lease lottery, and double encapsulation (hence MTU 1420). Permitted by restrict-host-namespaces' own carve-out for platform namespaces. The cost is stated in networkpolicy.yaml rather than papered over: no NetworkPolicy constrains a hostNetwork pod. A DaemonSet so the hand-made WAN forward cannot be invalidated by a reschedule; all three nodes answer the same keys, so failover is a one-line router change. Peer PUBLIC keys are plaintext in peers.yaml, which makes "who can get into my house" a reviewable PR diff. The only secret -- the server's private key -- is an out-of-band Secret: an agent must not mint a long-lived private key, and the two halves are joined only at pod start into a tmpfs that dies with the pod. Ships INERT-BUT-HEALTHY with zero peers. THREE off-repo manual steps remain (kernel module, server key, WAN forward) -- runbooks/vpn-remote-access.md. Touches clusters/** , so ci/approval-policy.sh will block pending the operator's /approve-override. Deliberately not posted by me (CLAUDE.md §1). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
clusters/
STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below. This document speaks as though no cluster exists and the platform is scaffolding. A three-node k3s cluster has existed for weeks and Flux has been reconciling against it. Gitea is live, the Actions runner
incluster-dindis online, andmainis a protected branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as describing a live gap rather than an expected stage — most importantly, L2/L3 conformance still does not run in CI (vars.CLUSTER_AVAILABLEis unset), which is a real coverage hole, not a phase.This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on documentation rather than measurement, and
mainwas unprotected with zero runners registered the whole time. Query the live API before asserting platform state.
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).