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]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-08-31 22:43:36 -05:00
1 parent e3c44368c8
commit 3f980d2a17
17 files changed
+269 -52

No files matched your search

+10 -4
View File
@@ -89,7 +89,13 @@ spec:
# userDataSecretRef only if needed). qemu-guest-agent gives the KubeVirt AgentConnected
# signal (proves cloud-init ran); install-uos resolves the latest UOS linux-x64 build from
# Ubiquiti's PUBLIC firmware API (no auth) and installs it non-interactively (UI on :11443).
# NOTE: apps-vms has NO postBuild.substituteFrom, so shell ${...} below is NOT envsubst'd.
# NOTE: apps-vms NOW carries postBuild.substituteFrom cluster-vars (needed for
# ${UNIFI_L4_VIP} in 50-service-l4.yaml), so Flux envsubst DOES run over this userData.
# Every shell brace-expansion below is therefore escaped as $${...}, which envsubst emits
# as a literal ${...} — verified with `flux envsubst`. Bare $VAR and $(...) are left alone
# by envsubst and need no escaping. NEVER write an unescaped ${...} here, and never write
# ${ARR[0]} even escaped-adjacent: envsubst hard-fails on `[` ("missing closing brace")
# and would break the whole apps-vms apply.
userData: |-
#cloud-config
hostname: unifi
@@ -103,11 +109,11 @@ spec:
content: |
#!/usr/bin/env bash
set -euo pipefail
if [ -z "${UOS_URL:-}" ]; then
if [ -z "$${UOS_URL:-}" ]; then
UOS_URL="$(curl -fsSL https://fw-update.ui.com/api/firmware-latest | jq -r '._embedded.firmware|map(select(.product=="unifi-os-server" and .platform=="linux-x64"))|sort_by(.version_major,.version_minor,.version_patch)|last._links.data.href')"
fi
[ -n "${UOS_URL}" ] && [ "${UOS_URL}" != "null" ] || { echo "no UOS URL"; exit 1; }
t="$(mktemp)"; echo "downloading ${UOS_URL}"; curl -fL --retry 5 --retry-delay 10 -o "$t" "$UOS_URL"; chmod +x "$t"; echo "installing"; yes | "$t"; echo "done"
[ -n "$${UOS_URL}" ] && [ "$${UOS_URL}" != "null" ] || { echo "no UOS URL"; exit 1; }
t="$(mktemp)"; echo "downloading $${UOS_URL}"; curl -fL --retry 5 --retry-delay 10 -o "$t" "$UOS_URL"; chmod +x "$t"; echo "installing"; yes | "$t"; echo "done"
runcmd:
- [ systemctl, enable, --now, qemu-guest-agent ]
- [ systemctl, enable, --now, podman.socket ]
+11 -7
View File
@@ -8,8 +8,9 @@
# CANNOT express: raw TCP on non-HTTP ports (8080 inform, 6789), UDP at all (3478 STUN, 10001
# discovery), or the device-facing protocols that never speak HTTP. UniFi device ADOPTION rides
# exactly those L4 ports. So the controller is exposed as a type=LoadBalancer Service whose VIP
# is pulled from the CiliumLoadBalancerIPPool `default-pool` (192.168.1.53-.60) and ARP-announced
# on the flat LAN by the existing CiliumL2AnnouncementPolicy `default-l2` — the SAME L2/LB-IPAM
# is pulled from the CiliumLoadBalancerIPPool `default-pool` (dev 192.168.1.53-.60 — the
# CILIUM_LB_POOL_START/STOP cluster-vars knobs) and ARP-announced on the flat LAN by the existing
# CiliumL2AnnouncementPolicy `default-l2` — the SAME L2/LB-IPAM
# machinery the Gateway ingress VIP already uses (platform-config/cilium/*). No new Cilium config
# is required; these Services just consume the pool.
#
@@ -23,8 +24,11 @@
# into the guest. externalTrafficPolicy: Local preserves the LAN client source IP and keeps the
# VIP pinned to the node running the VM (also what L2 announcement does).
#
# FLAG: confirm 192.168.1.54 is free, inside default-pool, and reserved in tofu (the Gateway
# already holds one pool IP, typically .53). Adjust if it collides.
# VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside
# default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53).
# Substituted by the `apps-vms` Flux Kustomization, which carries postBuild.substituteFrom for
# exactly this token; every shell ${...} in 30-virtualmachine.yaml's cloud-init is escaped as $${...}
# accordingly. A new environment MUST set UNIFI_L4_VIP to a free address in ITS pool.
#
# REUSABLE PATTERN (the deferred `minecraft` :25565/L4 problem is the same shape): expose an
# L4-only workload as a Cilium LoadBalancer on a pinned, L2-announced VIP with a per-app sharing
@@ -39,8 +43,8 @@ metadata:
app.kubernetes.io/name: unifi
annotations:
lbipam.cilium.io/sharing-key: "unifi"
lbipam.cilium.io/ips: "192.168.1.54"
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> 192.168.1.54.
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP.
# This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443.
external-dns.alpha.kubernetes.io/hostname: "unifi.home.keithjackson.dev"
spec:
@@ -65,7 +69,7 @@ metadata:
app.kubernetes.io/name: unifi
annotations:
lbipam.cilium.io/sharing-key: "unifi" # same key + same IP => shares the VIP with -tcp
lbipam.cilium.io/ips: "192.168.1.54"
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
+3 -1
View File
@@ -74,11 +74,13 @@ spec:
# (defaults to the Cluster name), so objects land under s3://cnpg/nextcloud-db/. Path-style HTTP.
# Credentials: the scoped key from the in-namespace SOPS Secret nextcloud-db-backup-credentials
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://cnpg
endpointURL: http://192.168.1.9:9000
endpointURL: ${S3_BACKUP_ENDPOINT}
s3Credentials:
accessKeyId:
name: nextcloud-db-backup-credentials
+3 -1
View File
@@ -75,11 +75,13 @@ spec:
# (defaults to the Cluster name), so objects land under s3://cnpg/semaphore-db/. Path-style HTTP.
# Credentials: the scoped key from the in-namespace SOPS Secret semaphore-db-backup-credentials
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: s3://cnpg
endpointURL: http://192.168.1.9:9000
endpointURL: ${S3_BACKUP_ENDPOINT}
s3Credentials:
accessKeyId:
name: semaphore-db-backup-credentials
+25
View File
@@ -40,6 +40,31 @@ they add no resources of their own and never copy/fork a base manifest. Promotio
([`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:
+18 -4
View File
@@ -23,7 +23,21 @@ spec:
kind: GitRepository
name: flux-system
path: ./apps/vms
# NO postBuild.substituteFrom: Flux's envsubst would run over the ENTIRE manifest, including the
# VM cloud-init shell scripts, and mangle every `${...}` (e.g. ${UOS_URL}, ${PIPESTATUS[0]}) —
# corrupting the in-guest install. VM manifests reference no cluster-vars, so substitution is
# unneeded here. A future VM that needs a cluster-var must escape its shell `${...}` as `$${...}`.
# postBuild.substituteFrom ENABLED 2026-08-31 (R-ENV-02). It was previously omitted because Flux's
# envsubst runs over the ENTIRE manifest — including VM cloud-init shell scripts — and would mangle
# every `${...}`; the omission was safe only while no VM manifest referenced a cluster-var. It now
# does: apps/vms/unifi/50-service-l4.yaml pins the UniFi L4 VIP from ${UNIFI_L4_VIP} instead of the
# hardcoded dev-LAN literal that would have shipped 192.168.1.54 onto the PROD LAN.
#
# The escape contract the old comment prescribed is now in force and MUST be honoured by every VM
# manifest under this path (verified with `flux envsubst`):
# `${VAR}` -> substituted (empty when unset) => escape shell braces as `$${VAR}`, which
# envsubst emits back as a literal `${VAR}`.
# `$VAR` -> left alone by envsubst => bare shell vars need NO escaping.
# `$(cmd)` -> left alone by envsubst => command substitution needs NO escaping.
# `${ARR[0]}` (e.g. ${PIPESTATUS[0]}) -> HARD ERROR "missing closing brace", which fails this
# whole Kustomization. NEVER use an indexed expansion here, escaped or not.
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
+50 -7
View File
@@ -21,13 +21,56 @@ data:
DNS_ZONE: "keithjackson.dev" # the actual Cloudflare ZONE (BASE_DOMAIN is a subdomain of it);
# ExternalDNS domain-filter must be the zone, not the subdomain
ACME_CLUSTER_ISSUER: "letsencrypt-staging" # dev: staging | prod: letsencrypt-prod (ADR-0006)
GATEWAY_VIP: "10.73.0.240" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
# ingress VIP of the `shared` Gateway (first IP of the Cilium
# LB IP pool 10.73.0.240-.250, platform-config/cilium/l2-ip-pool.yaml).
# coredns-custom answers *.home.keithjackson.dev with THIS so pod->
# public-hostname traffic hairpins through the Gateway (correct SNI ->
# wildcard cert). Base default = dev VIP; a prod overlay must set the
# prod LAN VIP alongside the prod L2 pool range when hardware is built.
GATEWAY_VIP: "192.168.1.53" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
# ingress VIP of the `shared` Gateway. MUST equal the FIRST address of
# ${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP} below — the Gateway
# takes the pool's first free IP, and coredns-custom answers
# *.home.keithjackson.dev with THIS so pod -> public-hostname traffic
# hairpins through the Gateway (correct SNI -> wildcard cert).
# Base default = the DEV LAN VIP (was 10.73.0.240, an address on the
# long-RETIRED libvirt network — corrected 2026-08-31 so an overlay that
# forgets this knob at least lands on a REAL network). A prod overlay
# MUST still set the prod LAN VIP alongside the prod L2 pool range.
# --- LAN addressing (R-ENV-02) — the ONE block every new environment must re-supply -------------
# These were HARDCODED into base manifests until 2026-08-31, which broke the golden constraint:
# `clusters/prod` would have rendered the DEV LAN (192.168.1.0/24) onto the prod LAN (192.168.2.0/24)
# — a silent, cross-environment IP collision. They are knobs now. Base defaults ARE the dev values
# (same convention as GATEWAY_VIP above: the base default is the known-good working value), so the
# dev overlay restates them verbatim and a prod overlay MUST override EVERY knob in this block
# before `flux bootstrap --path=clusters/prod`.
#
# Substitution coverage (verify before adding a knob here): every path that consumes these declares
# postBuild.substituteFrom cluster-vars — platform (K3S_API_HOST), platform-config
# (CILIUM_LB_POOL_*, S3_BACKUP_ENDPOINT), crafty (CRAFTY_L4_VIP), apps-wave-0 (S3_BACKUP_ENDPOINT),
# velero (S3_BACKUP_ENDPOINT), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an UNSUBSTITUTED path would
# ship the literal string into the cluster.
# k3s API endpoint Cilium's kube-proxy replacement dials (platform/cilium/helmrelease.yaml).
# [parity] MUST equal the Ansible bootstrap's k3s_api_host (infra/ansible/roles/cilium/defaults/
# main.yml -> cilium-values.yaml.j2, already inventory-parameterized). Wrong value = Cilium cannot
# reach the API server on adopt and the CNI breaks. Port stays 6443 in both envs, so it is NOT a knob.
K3S_API_HOST: "192.168.1.50"
# CiliumLoadBalancerIPPool `default-pool` (platform-config/cilium/l2-ip-pool.yaml): the VIP range
# L2-announced on the flat LAN. Must sit OUTSIDE the node IPs and any DHCP scope, and must match the
# tofu reservation (cilium_l2_vip_range). INVARIANT: ${GATEWAY_VIP} must be INSIDE this range and in
# practice EQUAL to START — the `shared` Gateway pins no address, so LB-IPAM hands it the pool's
# first free IP. (⚠ the operator-supplied PROD values currently break this; see l2-ip-pool.yaml and
# the flagged block in clusters/prod/kustomization.yaml.)
CILIUM_LB_POOL_START: "192.168.1.53"
CILIUM_LB_POOL_STOP: "192.168.1.60"
# Pinned VIPs carved out of that pool by name (lbipam.cilium.io/ips). These are L4 workloads the
# HTTPS Gateway cannot express (raw TCP/UDP), so they hold their own stable address:
UNIFI_L4_VIP: "192.168.1.54" # UniFi controller VM: inform/STUN/adoption (apps/vms/unifi)
CRAFTY_L4_VIP: "192.168.1.55" # Crafty: Minecraft Java (TCP) + Bedrock (UDP) game ports
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
# nextcloud-db, semaphore-db) and Velero's backupStorageLocation s3Url. Full URL incl. scheme+port.
# Prod needs its OWN target: a backup that lands on the dev LAN's MinIO is not off-cluster DR.
S3_BACKUP_ENDPOINT: "http://192.168.1.9:9000"
# --- storage (ADR-0003) ---
LONGHORN_DEFAULT_REPLICA_COUNT: "3" # default StorageClass replica count (3 on 3 nodes)
+33 -1
View File
@@ -148,12 +148,44 @@ patches:
- op: replace
path: /data/ACME_CLUSTER_ISSUER
value: "letsencrypt-prod"
# --- dev LAN addressing (192.168.1.0/24 on the Proxmox LAN) -----------------------------
# EVERY knob in cluster-vars' "LAN addressing" block is restated here EXPLICITLY, even where it
# equals the base default. These values are what the LIVE dev cluster is running right now, so
# the substituted output is byte-identical to the pre-parameterization hardcoded manifests
# (verified 2026-08-31). Never let this block drift from the live cluster.
#
# Split-horizon DNS target: the Cilium-L2 VIP of the `shared` Gateway on the Proxmox LAN
# (base default 10.73.0.240 was the old libvirt VIP). coredns-custom answers
# (= CILIUM_LB_POOL_START, the pool's first address). coredns-custom answers
# *.home.keithjackson.dev with this so in-cluster OIDC clients reach Keycloak via the Gateway.
- op: replace
path: /data/GATEWAY_VIP
value: "192.168.1.53"
# k3s API endpoint Cilium's kube-proxy replacement dials — the primary control-plane node
# (k3s-1). [parity] equals the Ansible inventory's k3s_api_host for dev.
- op: replace
path: /data/K3S_API_HOST
value: "192.168.1.50"
# Cilium LB IP pool = the tofu reservation cilium_l2_vip_range .53-.60, outside the node IPs
# (.50/.51/.52) and outside the DHCP scope. .53 Gateway, .54 unifi, .55 crafty; .56-.60 free.
- op: replace
path: /data/CILIUM_LB_POOL_START
value: "192.168.1.53"
- op: replace
path: /data/CILIUM_LB_POOL_STOP
value: "192.168.1.60"
# Pinned L4 VIPs out of that pool (lbipam.cilium.io/ips). Changing either MOVES a live device-
# facing endpoint: unifi is the inform/STUN target every adopted UniFi device holds.
- op: replace
path: /data/UNIFI_L4_VIP
value: "192.168.1.54"
- op: replace
path: /data/CRAFTY_L4_VIP
value: "192.168.1.55"
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
# barmanObjectStore and Velero's backupStorageLocation write to.
- op: replace
path: /data/S3_BACKUP_ENDPOINT
value: "http://192.168.1.9:9000"
# dev mirrors prod's 3-node topology, so the replicated tier keeps 3 replicas.
- op: replace
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
+64 -4
View File
@@ -49,8 +49,68 @@ patches:
- op: replace
path: /data/VELERO_RETENTION
value: "720h"
# ==========================================================================================
# PROD LAN ADDRESSING (192.168.2.0/24). Nodes: k3s-prod-1/2/3 = .10 / .11 / .12.
# ==========================================================================================
# These knobs were HARDCODED to the dev LAN inside base manifests until 2026-08-31; prod would
# have come up ANNOUNCING DEV ADDRESSES (192.168.1.x) on the prod LAN. They are knobs now, and
# this overlay supplies every one of them. Anything the operator has NOT yet decided stays an
# INVALID `TBD-PROD-*` placeholder on purpose: base defaults are the DEV values, so an OMITTED
# op means prod silently inherits a dev address — far worse than a failed apply. `TBD-PROD-*`
# is not a valid IP/URL, so Cilium CRD validation / LB-IPAM / CNPG / Velero reject it LOUDLY.
#
# ------------------------------------------------------------------------------------------
# ADDRESS PLAN (operator, 2026-08-31) — see infra/PROD-PROVISIONING.md §4 for the full table.
# ------------------------------------------------------------------------------------------
# nodes 192.168.2.10 / .11 / .12 (Ansible inventory)
# control-plane VIP 192.168.2.9 (Ansible group_vars/k3s_prod — NOT here)
# ingress Gateway VIP 192.168.2.20 (GATEWAY_VIP, below)
# Cilium LB pool 192.168.2.20 - .30 (CILIUM_LB_POOL_*, below)
#
# CONTAINMENT INVARIANT SATISFIED: GATEWAY_VIP (.20) IS CILIUM_LB_POOL_START, mirroring dev
# (.53 of .53-.60). This matters because the `shared` Gateway (platform/gateway/gateway.yaml)
# pins NOTHING — no `lbipam.cilium.io/ips`, no spec.addresses (unlike platform/crafty and
# apps/vms/unifi, which DO pin) — so Cilium LB-IPAM assigns it the FIRST FREE address of a
# matching pool block. Had GATEWAY_VIP stayed outside every pool (the original .21-.30
# proposal), 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 hairpin traffic (OIDC clients -> Keycloak/Grafana) while looking healthy.
# KEEP GATEWAY_VIP == CILIUM_LB_POOL_START when changing either.
# The control-plane VIP (.9) is deliberately OUTSIDE this pool: kube-vip floats it, and
# LB-IPAM must never be able to hand it to a Service.
- op: replace
path: /data/GATEWAY_VIP
value: "192.168.2.20"
# Cilium LB IP pool, operator-supplied: 192.168.2.20-.30 (START == GATEWAY_VIP, see above).
# Mirror this range into the prod tofu reservation (cilium_l2_vip_range) if prod ever gets one.
- op: replace
path: /data/CILIUM_LB_POOL_START
value: "192.168.2.20"
- op: replace
path: /data/CILIUM_LB_POOL_STOP
value: "192.168.2.30"
# --- still UNDECIDED -> loud placeholders (must be real before `flux bootstrap`) ------------
# k3s API endpoint Cilium's kube-proxy replacement dials. This is the prod CONTROL-PLANE VIP
# (kube-vip), NOT .20 (the ingress VIP) and NOT the single node address .10. It is owned
# Ansible-side (prod inventory k3s_api_host) and MUST equal that value — [parity] contract in
# platform/cilium/helmrelease.yaml. Not yet decided.
- op: replace
path: /data/K3S_API_HOST
value: "192.168.2.10"
# Pinned L4 VIPs, both carved out of the pool by lbipam.cilium.io/ips. Not yet assigned for
# prod. Leaving the dev literals in place would hand a prod LoadBalancer a dev-LAN address, so
# they stay placeholders; whatever is chosen MUST fall inside [POOL_START, POOL_STOP] and not
# collide with the Gateway VIP or the node IPs (.10/.11/.12).
- op: replace
path: /data/UNIFI_L4_VIP
value: "TBD-PROD-UNIFI-L4-VIP"
- op: replace
path: /data/CRAFTY_L4_VIP
value: "TBD-PROD-CRAFTY-L4-VIP"
# Off-cluster DR target. Dev's versitygw/MinIO (192.168.1.9:9000) sits on the DEV side of the
# network — a prod backup written there is not off-cluster DR and would mix two environments'
# PITR streams in one bucket. Operator supplies the prod endpoint (full URL incl. scheme+port).
- op: replace
path: /data/S3_BACKUP_ENDPOINT
value: "TBD-PROD-S3-BACKUP-ENDPOINT"
# TODO(WS-INFRA, Phase 7): pin prod node placement once the bare-metal role labels exist.
# TODO(WS-NET, Phase 7): set GATEWAY_VIP to the prod LAN ingress VIP (add a `replace` op here)
# at the same time the prod Cilium LB IP pool range is supplied — the split-horizon
# coredns-custom (platform/coredns) answers *.home.keithjackson.dev with ${GATEWAY_VIP}, so
# the prod value must match the prod pool's Gateway address. Base default is the dev VIP.
@@ -1,6 +1,8 @@
---
# CiliumL2AnnouncementPolicy — ARP-announce LoadBalancer VIPs on the node LAN so hosts on
# 192.168.1.0/24 can reach Gateway listeners at the pool IP (l2announcements.enabled=true in the
# CiliumL2AnnouncementPolicy — ARP-announce LoadBalancer VIPs on the node LAN so LAN hosts (dev
# 192.168.1.0/24, prod 192.168.2.0/24) can reach Gateway listeners at the pool IP. The RANGE itself
# is env-scoped in l2-ip-pool.yaml via CILIUM_LB_POOL_START/STOP; this policy is env-agnostic and
# needs no cluster-vars knob (l2announcements.enabled=true in the
# Cilium HelmRelease turns on the controller; this policy scopes WHAT/WHERE it announces).
# Announces on the primary ethernet interface. On Proxmox VMs the NIC is typically ens18/eth0;
# `^e(n|th)` matches ens*/enp*/eth* so the exact name need not be pinned. VERIFY-AT-BRINGUP: if
+24 -7
View File
@@ -1,16 +1,33 @@
---
# CiliumLoadBalancerIPPool — reserved VIP range for LoadBalancer Services. Gateway API listeners
# (gatewayClassName: cilium) get their address from here. Range matches the tofu reservation
# (cilium_l2_vip_range 192.168.1.53-.60) on the Proxmox LAN 192.168.1.0/24. These addresses sit
# OUTSIDE the node IPs (.50/.51/.52) and any DHCP scope, so nothing else claims them.
# (gatewayClassName: cilium) get their address from here. The range must match the environment's tofu
# reservation (cilium_l2_vip_range) and sit OUTSIDE the node IPs and any DHCP scope, so nothing else
# claims them.
#
# NOTE (dev): the range is hardcoded to the dev LAN reservation. A prod overlay must supply the
# prod LAN range (parameterize via cluster-vars when prod hardware is built). See DECISIONS-LOG.
# R-ENV-02: the range was HARDCODED to the dev LAN (192.168.1.53-.60 on the Proxmox LAN 192.168.1.0/24,
# nodes .50/.51/.52) until 2026-08-31; it is now ${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP} from
# cluster-vars, substituted by the `platform-config` Flux Kustomization.
#
# CONTAINMENT INVARIANT — ${GATEWAY_VIP} MUST be inside this block, and in practice EQUAL to
# ${CILIUM_LB_POOL_START}. The `shared` Gateway (platform/gateway/gateway.yaml) pins NOTHING: it has
# no `lbipam.cilium.io/ips` annotation and no spec.addresses (unlike platform/crafty/service-l4.yaml
# and apps/vms/unifi/50-service-l4.yaml, which DO pin). Cilium's Gateway controller creates a
# `cilium-gateway-shared` LoadBalancer Service and LB-IPAM assigns it the FIRST FREE address of a
# matching pool block — so the Gateway lands on POOL_START, and dev's GATEWAY_VIP (.53) IS dev's
# POOL_START (.53-.60). A GATEWAY_VIP outside every pool is NOT allocatable: the Gateway would come
# up on POOL_START anyway while split-horizon coredns-custom keeps answering *.home.keithjackson.dev
# with the unannounced GATEWAY_VIP, blackholing in-cluster hairpin traffic while looking healthy.
#
# RESOLVED (operator, 2026-08-31): the invariant holds in BOTH environments. An earlier prod
# proposal put GATEWAY_VIP .20 outside a .21-.30 pool; the operator widened the prod pool to
# 192.168.2.20-.30 so GATEWAY_VIP (.20) IS prod's POOL_START, exactly as dev's .53 is dev's.
# Prod's kube-vip CONTROL-PLANE VIP (192.168.2.9) sits deliberately BELOW the pool so kube-vip
# and LB-IPAM can never contend for an address.
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
name: default-pool
spec:
blocks:
- start: "192.168.1.53"
stop: "192.168.1.60"
- start: "${CILIUM_LB_POOL_START}"
stop: "${CILIUM_LB_POOL_STOP}"
+5 -2
View File
@@ -45,9 +45,12 @@ spec:
# [parity] MUST equal Ansible: kubeProxyReplacement: true
kubeProxyReplacement: true
# [parity] MUST equal Ansible: k8sServiceHost/k8sServicePort = the k3s API endpoint
# (k3s_api_host/k3s_api_port). This is the primary control-plane node IP (192.168.1.50).
# (k3s_api_host/k3s_api_port). This is the primary control-plane node IP.
# Setting these wrong breaks Cilium's connection to the API server on adopt.
k8sServiceHost: "192.168.1.50"
# ${K3S_API_HOST} (cluster-vars, R-ENV-02): dev 192.168.1.50 | prod set in clusters/prod.
# The `platform` Flux Kustomization substitutes it; the Ansible side is already parameterized
# from inventory (k3s_api_host), so the parity contract holds per-environment, not per-literal.
k8sServiceHost: "${K3S_API_HOST}"
k8sServicePort: 6443
# --- Gateway API (ingress; replaces Traefik) — docs/09, ADR-0002 ---
# [parity] MUST equal Ansible: gatewayAPI.enabled: true
+4 -2
View File
@@ -12,8 +12,10 @@
# [parity] k3s kube-proxy disabled → Cilium replaces it (ADR-0001/0002)
kubeProxyReplacement: true
# [parity] = the k3s API endpoint (primary control-plane node IP) + port
k8sServiceHost: "192.168.1.50"
# [parity] = the k3s API endpoint (primary control-plane node IP) + port.
# Mirrors the HelmRelease's ${K3S_API_HOST} cluster-vars token (R-ENV-02). This file is NOT a
# kustomize resource, so nothing substitutes it — the token is shown with its dev value beside it.
k8sServiceHost: "${K3S_API_HOST}" # dev: 192.168.1.50
k8sServicePort: 6443
# [parity] ingress via Gateway API, no Traefik (docs/09)
gatewayAPI:
+8 -6
View File
@@ -6,7 +6,8 @@
# the crafty pod's network namespace. The Gateway API `shared` Gateway is an L7 HTTPS terminator and
# CANNOT express raw TCP on arbitrary ports or UDP at all. So the game ports are exposed as
# type=LoadBalancer Services whose VIP is pulled from the CiliumLoadBalancerIPPool `default-pool`
# (192.168.1.53-.60) and ARP-announced on the flat LAN by the existing CiliumL2AnnouncementPolicy
# (dev 192.168.1.53-.60; see CILIUM_LB_POOL_* in cluster-vars) and ARP-announced on the flat LAN by
# the existing CiliumL2AnnouncementPolicy
# `default-l2` (platform-config/cilium/*) — the SAME machinery the Gateway VIP and unifi already use.
# No new Cilium config is needed; these Services just consume the pool.
#
@@ -15,8 +16,9 @@
# control plane) is Keycloak-gated (via the oauth2-proxy sidecar); the game ports carry player traffic
# and must reach the server subprocesses directly.
#
# VIP: 192.168.1.55 — the next free address in default-pool (.53 = shared Gateway, .54 = unifi;
# verified free live 2026-08-28). PINNED via lbipam.cilium.io/ips so players have a stable target.
# VIP: CRAFTY_L4_VIP (cluster-vars, R-ENV-02; dev 192.168.1.55) — the next free address in
# default-pool (dev: .53 = shared Gateway, .54 = unifi; verified free live 2026-08-28). PINNED via
# lbipam.cilium.io/ips so players have a stable target. The `crafty` Flux Kustomization substitutes it.
# ONE VIP, TWO SERVICES: a LoadBalancer Service is single-protocol-friendly, so TCP and UDP split into
# two Services sharing the IP via lbipam.cilium.io/sharing-key (same pattern as unifi).
#
@@ -38,8 +40,8 @@ metadata:
app.kubernetes.io/name: crafty
annotations:
lbipam.cilium.io/sharing-key: "crafty"
lbipam.cilium.io/ips: "192.168.1.55"
# external-dns publishes A crafty-mc.home.keithjackson.dev -> 192.168.1.55 (the Java server target).
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
# external-dns publishes A crafty-mc.home.keithjackson.dev -> the pinned VIP (Java server target).
external-dns.alpha.kubernetes.io/hostname: "crafty-mc.home.keithjackson.dev"
spec:
type: LoadBalancer
@@ -60,7 +62,7 @@ metadata:
app.kubernetes.io/name: crafty
annotations:
lbipam.cilium.io/sharing-key: "crafty" # same key + same IP => shares the VIP with -tcp
lbipam.cilium.io/ips: "192.168.1.55"
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
+3 -1
View File
@@ -40,11 +40,13 @@ spec:
# to the Cluster name), so objects land under s3://cnpg/keycloak-db/. Path-style HTTP MinIO endpoint.
# Credentials: the scoped key from the in-namespace SOPS Secret keycloak-db-backup-credentials
# (a Pod only reads same-ns Secrets), kept in sync with platform/backup/minio-credentials.sops.yaml.
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000) — prod
# MUST point at its own off-cluster target. Substituted by the `platform-config` Kustomization.
backup:
retentionPolicy: "30d" # keep 30 days of base backups + WAL (PITR window); prunes older
barmanObjectStore:
destinationPath: s3://cnpg
endpointURL: http://192.168.1.9:9000
endpointURL: ${S3_BACKUP_ENDPOINT}
s3Credentials:
accessKeyId:
name: keycloak-db-backup-credentials
+3 -2
View File
@@ -1,7 +1,8 @@
---
# Velero — off-cluster backup/DR of cluster resources + PVC data (ADR-0003, R-STATE-03). LIVE.
#
# TARGET: the on-Proxmox MinIO S3 endpoint http://192.168.1.9:9000 (plain HTTP, path-style), bucket
# TARGET: the MinIO S3 endpoint ${S3_BACKUP_ENDPOINT} (cluster-vars; dev http://192.168.1.9:9000 on
# Proxmox — plain HTTP, path-style), bucket
# `velero`. region us-east-1 is a placeholder MinIO ignores (S3 clients still require one).
#
# WHY THIS SHAPE:
@@ -75,7 +76,7 @@ spec:
default: true
config:
region: us-east-1 # placeholder; MinIO ignores it (S3 clients require one)
s3Url: http://192.168.1.9:9000 # on-Proxmox MinIO, plain HTTP
s3Url: ${S3_BACKUP_ENDPOINT} # on-Proxmox MinIO, plain HTTP (cluster-vars)
s3ForcePathStyle: "true" # MinIO is path-style
credentials:
existingSecret: velero-cloud-credentials # SOPS Secret carrying the `cloud` INI key (ns velero)
+1 -1
View File
@@ -2,7 +2,7 @@
# doc pointer, kept for parity with the other components' layout).
#
# LIVE wiring (see helmrelease.yaml):
# - provider aws + velero-plugin-for-aws (init container) → MinIO S3 http://192.168.1.9:9000
# - provider aws + velero-plugin-for-aws (init container) → MinIO S3 ${S3_BACKUP_ENDPOINT} (dev: http://192.168.1.9:9000)
# (path-style, bucket `velero`, region us-east-1 placeholder).
# - deployNodeAgent:true + uploaderType:kopia → File System Backup of PVC data (no CSI data-mover).
# - snapshotsEnabled:false → no VolumeSnapshotLocation.