Files
home-network-k3s-impl/infra/PHASE1-STATUS.md
T
jakman85andClaude Opus 5 12c40d8c9c 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]>
2026-08-27 00:00:05 -05:00

7.2 KiB
Raw Blame History

Dev cluster — current state & reconnect guide

Last verified: 2026-08-26. Phase 1 is complete and long since exited; the cluster has been running the full platform (phases 2–6) for some time. This doc is how a session — human or agent — reconnects to it and re-establishes the toolchain.

History note. The cluster was originally 3 libvirt/KVM VMs on the workstation (10.73.0.0/24 NAT). That was retired after the workstation ran out of RAM/disk; the cluster now lives on Proxmox VE. Anything mentioning virsh, qemu:///system, tofu+dmacvicar/libvirt, or 10.73.0.x for the cluster is historical — including cluster.sh (retired) and PHASE1-SCOPE.md (historical scoping doc).

The cluster

Substrate Proxmox VE host, node proxmox, disks on the Storage ZFS datastore, bridge vmbr0
Nodes k3s-dev-1/2/3 at 192.168.1.50 / .51 / .52 — 6 vCPU, 16 GiB, Debian 12 (bookworm)
k3s v1.31.5+k3s1, 3 servers, embedded etcd (HA); no flannel / kube-proxy / Traefik
CNI Cilium 1.16.5 (kube-proxy replacement, L2 announcements, egress gateway)
kube-API kube-vip VIP 192.168.1.49:6443, tls-san'd on the serving cert
Ingress VIP 192.168.1.53 (shared Gateway). Pool 192.168.1.53–.60 for LB VIPs; UniFi holds .54
Storage Longhorn 1.7.3 — classes longhorn (default), longhorn-static, longhorn-vm (RWX/Block, migratable), longhorn-scratch. bulk (NFS CSI) is a non-provisioning stub pending OQ-2
Nested virt cpu_type = host → real KVM in-guest; KubeVirt + CDI run on it

Everything above is provisioned by IaC: tofu/ creates the VMs, cloud-init/ baselines first boot, ansible/ installs k3s, Cilium, kube-vip and the VPN egress gateway. Flux then reconciles the whole platform from clusters/dev.

Network path from the workstation (there is a NAT in the way)

The workstation is not on the cluster LAN. A pfSense NAT bridges the two:

Purpose Workstation-side target Forwards to
kube-API https://192.168.2.50:6443 kube-vip VIP 192.168.1.49:6443
Node SSH (Ansible only) 192.168.2.102:2250 / 2251 / 2252 192.168.1.50 / .51 / .52 port 22
Proxmox API https://192.168.2.102:8006/ the Proxmox host

TLS through the NAT validates because the kubeconfig sets tls-server-name: 192.168.1.49 — the VIP is in the serving cert's SAN list, so the NAT address does not have to be.

There is no SSH tunnel any more; the old tunnel was retired when kube-vip + the NAT landed.

Access

kubectl — Keycloak SSO (normal path). ~/.kube/config context k3s-oidc authenticates against the homelab realm via kubelogin (auth-code + PKCE, public client, no secret in the kubeconfig). Group membership maps to RBAC by platform/keycloak/k8s-oidc-rbac.yaml:

kubectl config use-context k3s-oidc
kubectl auth whoami     # -> oidc:<you>, groups [oidc:platform-admins ...]

oidc:platform-admins → cluster-admin, oidc:viewers → view. First use opens a browser, so this path needs an interactive desktop.

kubectl — cert kubeconfig (break-glass). The k3s admin cert kubeconfig from /etc/rancher/k3s/k3s.yaml on k3s-dev-1 (rewrite the server to https://192.168.2.50:6443 and add tls-server-name: 192.168.1.49). Fully independent of Keycloak — use this when OIDC, the gateway, or Keycloak itself is down. Headless contexts (CI jobs, containers, agents) also need this, since they cannot complete a browser login.

Node SSH. ssh -i ~/.ssh/home_network -p 2250 [email protected] (user k3s, NOPASSWD sudo). Ports 2251/2252 for nodes 2/3.

Toolchain — use the dev container, not the host

Every pinned tool (tofu, ansible, sops, age, flux, kubectl, helm and the full CI gate toolchain: yamllint, check-jsonschema, kyverno, chainsaw, yq) lives in the image:

infra/devcontainer/dev.sh build                    # once
infra/devcontainer/dev.sh run make -C ci gate      # exactly what CI runs
infra/devcontainer/dev.sh                          # interactive shell at /work

Unlike the libvirt era, tofu and ansible now run fine inside the container — the bpg/proxmox provider talks to the Proxmox API over the network and needs no host libvirt socket, which is what broke the old dmacvicar/libvirt provider in a container.

dev.sh mounts the repo at /work, ~/.ssh (read-only), ~/.config/sops (the age keyring — persisted on the host, or you lose access to every SOPS secret), the ansible-vault passphrase file, and the sibling spec repo read-only at /home-network-k3s so the R-TEST-06 immutability check runs instead of silently skipping.

Known gap. dev.sh defaults KUBECONFIG to /work/infra/ansible/.cache/dev.kubeconfig, a libvirt-era path that no longer exists, and does not mount ~/.kube. In-container kubectl therefore needs an explicit DEV_KUBECONFIG=<path to a break-glass cert kubeconfig> — the OIDC context cannot work there anyway (no browser). Not yet fixed.

Off-repo state you must not lose

What Where
SOPS age private key ~/.config/sops/age/keys.txt — losing it locks you out of every encrypted secret
Ansible vault passphrase ~/.config/home-k3s/ansible-vault-pass
Node SSH key ~/.ssh/home_network
Proxmox API token operator env only (PROXMOX_VE_API_TOKEN) — never a tofu var, never in state
tofu state infra/tofu/terraform.tfstate (gitignored) — absent on a fresh workstation; a tofu apply without it would try to recreate the live nodes
Generated SSO secrets ~/.config/home-k3s/keycloak-sso-secrets.txt

Health check

kubectl get nodes                                  # 3/3 Ready
flux get kustomizations                            # flux-system, platform, platform-config,
                                                   # apps-wave-0/1/2, apps-vms — all Ready
kubectl get helmreleases -A                        # all Ready
kubectl get cluster -A                             # CNPG: keycloak-db, nextcloud-db, semaphore-db
kubectl get vmi -A                                 # unifi Running

Flux's git source is inside the cluster — mind the loop

GitRepository/flux-system points at https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git: the in-cluster Gitea, which is itself an app Flux deploys in apps/wave-0. Resolution goes through split-horizon CoreDNS → Gateway VIP 192.168.1.53, so a Flux reconcile depends on Gitea, the Gateway, CoreDNS and the wildcard cert all being up.

In practice this is stable — Gitea's Deployment and PVC survive a reboot and come back without Flux's help, so a Proxmox host reboot self-heals (VMs auto-start, cluster reconverges). The loop only bites if Gitea is lost rather than restarted. The recovery path is the host bootstrap Gitea (bash bootstrap/gitea/bootstrap-gitea.sh) plus repointing the GitRepository at it — that is what the bootstrap/ tree exists for, and it is the reason the bootstrap Gitea is kept.

Push target. origin is that same in-cluster Gitea, so git push origin main is the deploy. There is no separate "apply" step and no GitHub remote.