docs: reconcile the documentation with the deployed reality

The docs had drifted badly behind ~85 commits of delivery. The root README still
said "Phase 0 — Foundations. No cluster exists yet"; the infra docs still
described a libvirt/KVM cluster that has been retired for two days; and
DECISIONS-LOG.md — the PM's authoritative record — stopped at 2026-08-25 AM.
Anyone following these docs would have been working from a false map.

Verified against the live cluster (3/3 nodes, 7/7 Flux Kustomizations Ready,
10/10 HelmReleases Ready) rather than inferred from commit messages.

README.md
  - Phase 0 -> a per-phase status table: 0-6 delivered and deployed, 7 not open.
  - Layout now shows the wave split, platform-config, apps/vms, infra, bootstrap.
  - Real conformance number (36/51, 70.6%) and how to regenerate it.
  - Devcontainer workflow; note that the gate was red and why.

infra/PHASE1-STATUS.md — rewritten. Was a `virsh`/libvirt resume guide for a
  cluster that no longer exists. Now: the Proxmox substrate, the pfSense NAT
  paths, both kubectl access paths (Keycloak OIDC normal / cert break-glass),
  the toolchain, off-repo state that must not be lost, and the fact that Flux's
  git source is an app inside the cluster (`git push origin main` IS the deploy).

infra/PHASE1-SCOPE.md — historical banner. The exit criteria and sizing
  rationale are still worth keeping; the libvirt execution steps are not.

infra/README.md — Proxmox, plus a map of the subtree and where the cluster
  actually lives (it is not on this workstation).

infra/devcontainer/README.md — the "run tofu and ansible on the HOST" limitation
  is OBSOLETE: it was a dmacvicar/libvirt polkit/PID problem, and bpg/proxmox
  talks to an HTTP API. Everything runs in the container now. Documents the full
  CI-gate toolchain and what each bind mount is load-bearing for (the age keyring
  and the read-only spec repo especially — without the latter, spec-immutability
  silently skips instead of verifying).

infra/tofu/README.md — was "Authoring only — nothing here has been applied". It
  is applied and live. Disk sizes corrected 40/100 -> 64/128 GiB (confirmed
  against the running nodes), pfSense NAT marked done, and a warning that
  terraform.tfstate is absent on a fresh workstation — an apply with empty state
  would try to recreate the three live nodes.

infra/tofu/variables.tf — section comment disagreed with its own defaults.

infra/cluster.sh — retired-with-instructions header. It silently drives libvirt
  domains that do not exist; behaviour unchanged, but it no longer lies.

apps/vms/unifi/README.md — was "NOT WIRED IN", 60Gi, and listed a file that has
  since been dropped. Now: live status, 40Gi, resolved open questions marked
  resolved, and the two constraints the next person will otherwise rediscover —
  KubeVirt's 2048B inline userData cap, and that apps-vms must never carry
  postBuild.substituteFrom (envsubst corrupts the cloud-init).

DECISIONS-LOG.md — backfilled the gap as one structured entry: the Proxmox
  migration, NAT + kube-vip, the twice-proven oauth2-proxy cold-start deadlock,
  the Flux source cutover, CI going live in-cluster, ten app onboardings and the
  renderer bugs they exposed, the wave split (verified: DBs adopted, not
  recreated — original ages intact, prune restored), KubeVirt storage, the UniFi
  VM, and kubectl SSO. Plus verified live state and the carried-forward open list.

Two corrections to the record while verifying:
  - kubectl OIDC is LIVE (`kubectl auth whoami` -> oidc:<user>,
    [oidc:platform-admins]), so the rolling restart 3472f1d deferred has
    happened.
  - UniFi OS Server is NOT installed in the guest. The VM is healthy (Ubuntu
    24.04.4, guest agent connected, live-migratable) but :11443 answers nothing,
    and the CNP explicitly permits cluster -> :11443, so it is genuinely not
    listening rather than a policy artifact.

Docs and comments only; no manifest semantics change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-08-27 00:00:05 -05:00
1 parent 4cf9572c58
commit 12c40d8c9c
10 files changed
+551 -147

No files matched your search

+146
View File
@@ -922,3 +922,149 @@ R-TLS-01/03 GREEN** (rewrote the probe for LE-prod system-trust + split-horizon
LESSON: heavy sustained node load (TCG-emulated VMs) can degrade Cilium datapath -> intermittent LB-VIP
reachability; a restart clears it. NOTE: after a host reboot, restart the bootstrap Gitea container
(`bash bootstrap/gitea/bootstrap-gitea.sh`) - it is Flux's source and does not auto-start; the k3s VMs auto-recover.
### BACKFILL — Proxmox migration, app tier, wave split, UniFi VM, kubectl SSO (2026-08-25 → 08-26)
**Process note first:** this log had gone stale at `1eb2cb7` (2026-08-25 AM) while `main` advanced
**~85 commits** to `3472f1d`. Everything below is reconstructed from those commits and **verified
against the live cluster on 2026-08-26**. The lesson is the obvious one: the log is the PM's
authoritative record and a two-day gap in it cost a session's worth of re-derivation. Append per
work item, not per session.
**1. INFRA — the dev cluster MIGRATED off libvirt onto Proxmox VE.** The workstation-hosted libvirt
cluster (3 VMs on `10.73.0.0/24`, 6 GiB each) was RAM/disk-starved — the KubeVirt work was OOMing
nodes (`971c730` had to temp-disable KubeVirt just to relieve pressure). Provisioning was ported
`dmacvicar/libvirt` → **`bpg/proxmox`** (`53ca698`), cloud-init moved from a netplan/meta-data seed
ISO to a Proxmox **user-data snippet** and then to API-native cloud-init with the rich baseline
pushed into the Ansible `node_baseline` role (`39123d7`, `a2392a4`). Nodes are now
**`192.168.1.50/.51/.52`, 6 vCPU / 16 GiB / 64 GiB OS + 128 GiB Longhorn data** (`8a8f28b`), Debian
12, `cpu_type=host` for nested KVM. Gotchas paid for along the way: the base image must be
downloaded as **import** content-type for `import_from` (`fca8d8f`); the Debian *genericcloud*
image kernel-panics on first boot under bpg — use the **generic** image + serial console
(`28d200c`, upstream bpg#1639); `qemu-guest-agent` must be enable-only when tofu leaves the virtio
channel off, or the baseline play fails (`1004c8e`).
**2. NETWORK — pfSense NAT, then kube-vip, then the tunnel died.** The workstation is not on the
cluster LAN. First cut routed Ansible over a pfSense SSH NAT (`192.168.2.102:225x` → node `:22`)
and decoupled `k3s_node_ip` from `ansible_host` (`1ff2531`) — `inventory/hosts.ini` is therefore
**hand-maintained and must never be regenerated** from `tofu output ansible_inventory`. The
kube-API initially needed an SSH tunnel (`0037fb4`). That was retired by **kube-vip**: a
control-plane HA VIP at **`192.168.1.49`**, added to the k3s `tls-san` list so TLS validates
through the NAT via `tls-server-name` (`60a58f0`, `81ef782`). Controller access is now direct:
`https://192.168.2.50:6443` → VIP. Gateway/ingress VIP moved to **`192.168.1.53`** on the Proxmox
LAN, and `GATEWAY_VIP` + the split-horizon CoreDNS answer + the DNS conformance tests were all
repointed (`604552e`, `63b87ff`).
**3. COLD-START DEADLOCK, again — and the durable lesson.** Bringing the platform up on the new
cluster re-hit the Phase-6 shape: `oauth2-proxy` sits in the `platform` `wait:true` tier but needs
Keycloak + trusted TLS + split-horizon DNS, so while it Failed the whole tier stayed not-Ready and
`platform-config` (which applies the fix) could never reconcile. Broken the same way as before —
temp-disable `oauth2-proxy` and its orphaned `platform-config` refs (`2fea1e2`, `efd6dbe`), let
platform go Ready, then re-enable (`e6b3a03`). KubeVirt/CDI re-enabled once the 16 GiB nodes
existed (`21669da`). **The FOLLOW-UP from Session 7 is still open and is now twice-proven: move
`oauth2-proxy` to a post-platform tier (`dependsOn: platform-config`) so a leaf forward-auth
service cannot gate its own dependencies.**
**4. FLUX SOURCE — now the in-cluster Gitea.** Bootstrap source repointed (`fff4499`), Gitea fixed
to mount its PVC at `/data` with a headless-install bootstrap (`73c4475`), then
`GitRepository/flux-system` cut over to **`https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git`**
(`d99d171`). Note the loop this creates: Flux's source is an app Flux deploys, reached via
split-horizon DNS → Gateway VIP. Stable in practice (Gitea's Deployment/PVC survive reboots
without Flux), but if Gitea is ever *lost* rather than restarted, recovery is the host bootstrap
Gitea in `bootstrap/`. `git push origin main` **is** the deploy — there is no other apply step.
**5. CI — the merge gate is now REAL and in-cluster.** Gitea Actions enabled with an in-cluster
`act_runner` + DinD, running the static gate as the single required check on `main` (`bc4519b`).
Fixes: kyverno CLI arch is `x86_64` not `amd64`, and the job must wait for DinD certs (`79e7f92`);
`make` masked spec-immutability's exit 3 as a 2, so `gate.sh` calls the script directly
(`0f75fa2`); `upload-artifact@v4` is unsupported by Gitea Actions (`67bc317`). The **whole test
toolchain is now baked into the devcontainer** (`bb48f67`) so `make -C ci gate` on a laptop is
byte-identical to CI (R-TEST-02). Kyverno itself needed two real fixes: the `default-deny-shape`
policy's scalar-array pattern was **inverted** and silently accepted Ingress-only policies — now
expressed via `deny.conditions` requiring both (`1b0848c`); and the fail-closed admission
controller was crashlooping into 502s until its resources were raised and dev replicas set to 2
(`28a34c4`). Chainsaw parallelism dropped 4→2, then heavy L2/L3 tests serialized, to stop dev etcd
overload cascades (`9f52f19`, `948c390`).
**6. APP TIER — 10 services onboarded through the renderer.** gitea, plex, sonarr, radarr,
qbittorrent, jackett (`b76fb7b`…`8392b52`), then seerr (`b6f72ea`), nextcloud (`4be6752`) and
semaphore (`77496f9`) — the last two being the **first CNPG-DB + OIDC-target patterns**. Renderer
bugs found by real onboarding, each fixed in the renderer rather than by hand-patching output:
world/internet egress allow emitted per egress-class (`7e8ae47`); **reciprocal ingress-allow** for
declared dependencies, without which a declared dep silently did not connect under default-deny
(`14beb85`, R-NET-02); east-west selectors **namespace-pinned** so they match the right pods
(`b871877`); `Recreate` strategy for Deployments mounting RWO PVCs, else rollouts deadlock
(`d32b5c4`). ServiceDefinition schema gained `env`/`envFrom`/`lifecycle`, retiring the bespoke
gitea bootstrap patch (`c8dd4f3`). Semaphore cost two root-causes: `fsGroup` via schema-1.3.0
`podSecurityContext` for non-root PVC write (`28498f1`), and **`SEMAPHORE_PORT` being shadowed by
kubelet service-link env injection** (`41d696e`) — a trap for any app whose config env name
collides with a Service name.
**7. APP TIER TOPOLOGY — monolithic `apps` split into WAVES.** One `wait:true` Kustomization over
all ~130 app resources meant one crashloop blocked every app and every new revision (it bit twice).
Split into `apps-wave-0/1/2`, each `dependsOn` the previous, wave assignment computed by
`catalog/render.py::compute_waves()` from the declared dependency graph. Design + the data-loss
cutover analysis are in **`DESIGN-waves.md`**. The cutover was executed as designed: `prune:false`
on the old `apps` first (`1bfb0db`), then the split (`36a1a4e`) so the waves **adopt** by identity
instead of delete/recreate. **VERIFIED 2026-08-26:** `nextcloud-db` carries
`kustomize.toolkit.fluxcd.io/name=apps-wave-0`, the CNPG Clusters retain their ORIGINAL ages
(22h/21h — not recreated), and `prune:true` is restored on the waves. No data was lost.
**8. PHASE 5 — storage for real VMs, and the UniFi VM.** CSI snapshots + smart-clone + a migratable
RWX `longhorn-vm` StorageClass landed to make KubeVirt **live migration** possible (`3586769`);
two KubeVirt alerts were plain wrong and firing falsely — a broken http CDI ServiceMonitor causing
`CDIOperatorDown` (`6a7e9d1`) and `KubeVirtControlPlaneDown` using a non-existent metric with the
wrong job label (`b57b3e9`). **UniFi OS Server** was then onboarded as a VM on a deliberately
**non-gating** track (`317b3f0`): `apps/vms/` reconciled by `apps-vms` (`wait:false`,
`dependsOn: platform-config`), because a VM's image import + in-guest install is legitimately
long-not-Ready and must never gate the app tier. Four cluster-level truths came out of it:
- **`nonroot-devices: true`** on the k3s servers — the non-root CDI importer must open a raw Block
device k8s never chowns for it (`df5979f`).
- **`longhorn-scratch`**, a single-replica class for CDI scratch; the 3-replica default failed the
import with insufficient storage (`7a3da96`).
- **KubeVirt caps inline `userData` at 2048 bytes.** The rich cloud-init exceeded it; a
`userDataSecretRef` was tried and reverted; shipped state is a **minimal ~1.1 KB inline**
payload (`e99e3d1`, `760084e`, `ab59202`). The `install-uos` helper did not survive the cut.
- **`apps-vms` must NOT carry `postBuild.substituteFrom`** — Flux envsubst rewrote `${...}` inside
the cloud-init and corrupted it (`da1f833`). Do not restore it for consistency with the waves.
**9. PHASE 6 — kubectl login via Keycloak SSO (`3472f1d`).** A **public** `kubernetes` client with
PKCE (no secret in any kubeconfig), a groups mapper, kube-apiserver OIDC flags via the k3s_server
role, and `k8s-oidc-rbac.yaml` binding `oidc:platform-admins` → `cluster-admin`,
`oidc:viewers` → `view`. The cert kubeconfig is untouched and remains a fully independent
**break-glass** path. **VERIFIED LIVE:** `kubectl auth whoami` returns
`oidc:<user>` with `[oidc:platform-admins]` — so the API-server flags are active; the rolling
restart the commit message deferred has since happened.
**LIVE STATE VERIFIED 2026-08-26 (fully green):** 3/3 nodes Ready (k3s v1.31.5); **7/7 Flux
Kustomizations Ready** at `3472f1d` (flux-system, platform, platform-config, apps-wave-0/1/2,
apps-vms); **10/10 HelmReleases Ready**; zero failing pods; 3 CNPG Clusters healthy (keycloak,
nextcloud, semaphore); UniFi VMI Running with guest agent connected (Ubuntu 24.04.4,
LiveMigratable). Conformance scaffold coverage **36/51 (70.6%)**.
**⛔ OPEN — `main` is RED on the CI lint gate.** `make -C ci lint` fails with **yamllint `commas`
errors in the hand-authored `apps/vms/unifi/` manifests** (`30-virtualmachine.yaml`,
`40-networkpolicy.yaml`, `50-service-l4.yaml` — aligned inline comment tables using multiple spaces
after a comma). Every other static stage is green in the devcontainer: spec-immutability ✅
(spec pinned at `b633b7f`, matching the sibling repo), Kyverno L0 ✅, L1 render 3/3 ✅. This is
cosmetic whitespace, but it **blocks the merge gate** and was introduced with the UniFi commits —
i.e. the VM work went in without the gate passing. Fix is a whitespace pass; flagged, not yet done.
**OPEN / NEXT (carried forward):**
1. Fix the lint RED above.
2. **Install UniFi OS Server in the guest** — the VM runs bare Ubuntu; `:11443` answers nothing
(CNP permits `cluster`→`:11443`, so it is genuinely not listening). Install is interactive with
a rotating signed URL — needs an operator decision (hand-install vs. a verified silent path).
3. **`R-STATE-02`** — the last safety-critical requirement with no L3 test (Longhorn node-loss).
Currently exempted via `DOCUMENTED_GAPS`.
4. **oauth2-proxy → post-platform tier** (twice-proven cold-start deadlock).
5. **OQ-BKP** — off-cluster backup target; gates Velero, CNPG DR depth, and `R-STATE-03`.
6. **OQ-2** — the `bulk` StorageClass exists but has `server/share: REPLACE-ME` and provisions
nothing; plex/arr/qbittorrent ship config-only until it is resolved.
7. **image-automation** still authored-but-disabled (needs a Gitea write-cred).
8. **Coverage matcher** — real requirements (`R-ENV-02`, `R-GITOPS-01`) are exempted as
DOCUMENTED_GAPS only because the path→requirement matcher is coarse. Fix the matcher (WS-TEST).
9. **`dev.sh` KUBECONFIG** defaults to the dead libvirt-era path
`infra/ansible/.cache/dev.kubeconfig` and `~/.kube` is not mounted — in-container kubectl needs
an explicit `DEV_KUBECONFIG` pointing at a break-glass cert kubeconfig (OIDC needs a browser).
10. **tofu state** is absent on a fresh workstation; a `tofu apply` with empty state would try to
recreate the three live nodes. Recover the state file or `tofu import` before planning changes.