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]>
102 lines
5.3 KiB
Markdown
102 lines
5.3 KiB
Markdown
# infra/tofu — dev/test cluster nodes (OpenTofu + Proxmox VE)
|
||
|
||
Provisions the "Create" lifecycle stage (docs/02): 3 cloud-init'd VMs on the
|
||
**Proxmox VE** box, each with an imported OS disk and a blank Longhorn data disk.
|
||
(Migrated from the old memory-constrained libvirt host; provider is now `bpg/proxmox`.)
|
||
|
||
**APPLIED AND LIVE.** These three VMs are the running dev cluster — `k3s-dev-1/2/3`, k3s
|
||
v1.31.5, carrying the whole platform. Treat any `tofu apply/destroy` here as an operation on
|
||
production-for-dev, and note the state file caveat below.
|
||
|
||
Serves **R-ENV-01/02**, **R-PLAT-01**, ADR-0001 (k3s substrate).
|
||
|
||
## What it builds (defaults = the LIVE cluster, verified 2026-08-26)
|
||
|
||
| Resource | Value |
|
||
|---|---|
|
||
| Proxmox node | `proxmox` |
|
||
| Disk datastore | `Storage` (ZFS, 4.3TB) |
|
||
| Image/snippet datastore | `local` (dir; content = iso + **snippets**) |
|
||
| Bridge | `vmbr0` (no VLAN) |
|
||
| Nodes | `k3s-dev-1/2/3` at `192.168.1.50/.51/.52`, gw `192.168.1.1`, DNS `192.168.1.18` |
|
||
| Per node | 6 vCPU, 16 GiB RAM, **cpu type `host`** (nested virt for KubeVirt — real KVM confirmed in-guest) |
|
||
| OS disk | **64 GiB**, virtio0 (`/dev/vda`, boot), imported from the Debian 12 cloud image |
|
||
| Data disk | **128 GiB**, virtio1 (`/dev/vdb`) → `/var/lib/longhorn` (mounted by cloud-init; Longhorn reports ~125 GiB usable per node) |
|
||
| Cloud-init | native `initialization` (static IP + DNS) + user-data **snippet** for the baseline |
|
||
| Reserved | `192.168.1.53–.60` for Cilium L2 ingress VIPs (surfaced in outputs). In use: `.53` shared Gateway, `.54` UniFi |
|
||
|
||
## ⚠️ State file
|
||
|
||
`terraform.tfstate` is gitignored and lives only on the workstation that ran the apply. On a
|
||
**fresh workstation it is absent** — and a `tofu apply` with empty state will try to *create* three
|
||
VMs that already exist. Recover the state file (or `tofu import` the existing VMs) before running
|
||
anything that plans changes. `tofu plan` against empty state is itself harmless, but read the plan:
|
||
"3 to add" means you are missing state, not that the cluster is gone.
|
||
|
||
All sizing/IPs/counts/placement are variables — see `variables.tf`.
|
||
|
||
## Files
|
||
- `versions.tf` — OpenTofu `1.9.0` pin + `bpg/proxmox ~> 0.66`; provider block with
|
||
endpoint scheme-normalization (see below).
|
||
- `variables.tf` — every knob (Proxmox placement, counts, sizing, IPs, image, SSH key).
|
||
- `main.tf` — cloud image download, per-node user-data snippet, and the 3 VMs.
|
||
- `outputs.tf` — node IPs/names, `primary_node_ip`, `k3s_api_endpoint`, rendered
|
||
`ansible_inventory`.
|
||
- `terraform.tfvars.example` — copy to `terraform.tfvars`; only `ssh_authorized_key` required.
|
||
|
||
## Operator prerequisites (before `tofu apply`)
|
||
|
||
1. **Enable `snippets` content on the `local` datastore.** The rich first-boot
|
||
baseline (users, packages, kernel modules, Longhorn mount, swap-off) is shipped
|
||
as a cloud-init user-data snippet. In the Proxmox UI: *Datacenter → Storage →
|
||
`local` → Edit → Content → add **Snippets***. Without this the
|
||
`proxmox_virtual_environment_file` (user-data) resource fails. (Alternatively
|
||
point `image_datastore` at any datastore that has snippets enabled.)
|
||
2. **Endpoint scheme.** If the operator env file exports `PROXMOX_VE_ENDPOINT`
|
||
without `https://`, the provider talks HTTP and eats a 301. Either fix the env
|
||
file to include the scheme, or export `TF_VAR_proxmox_endpoint="$PROXMOX_VE_ENDPOINT"`
|
||
so tofu normalizes it (prepends `https://`).
|
||
3. **pfSense NAT** — **DONE, live.** The workstation reaches the nodes and the kube-API
|
||
across it: SSH `192.168.2.102:2250/2251/2252` → `192.168.1.50/.51/.52:22`, and kube-API
|
||
`192.168.2.50:6443` → kube-vip VIP `192.168.1.49:6443`. `infra/ansible/inventory/hosts.ini`
|
||
is **hand-maintained** for this mapping — do **not** regenerate it from
|
||
`tofu output -raw ansible_inventory`, which knows nothing about the NAT and would overwrite it.
|
||
|
||
## Auth (env only — never in tofu vars/state)
|
||
The provider reads these from the environment; the **API token is never a tofu
|
||
variable**, so it never enters state, vars, or plan output:
|
||
```
|
||
export PROXMOX_VE_ENDPOINT="https://192.168.2.102:8006/"
|
||
export PROXMOX_VE_API_TOKEN="user@realm!tokenid=SECRET"
|
||
export PROXMOX_VE_INSECURE="true"
|
||
export TF_VAR_proxmox_endpoint="$PROXMOX_VE_ENDPOINT" # optional; enables normalization
|
||
```
|
||
|
||
## Usage (operator — NOT run here)
|
||
```
|
||
cd infra/tofu
|
||
cp terraform.tfvars.example terraform.tfvars # set ssh_authorized_key
|
||
# source the operator env file (exports the PROXMOX_VE_* above)
|
||
tofu init
|
||
tofu plan
|
||
tofu apply
|
||
# hand the node IPs to Ansible:
|
||
tofu output -raw ansible_inventory > ../ansible/inventory/hosts.ini
|
||
```
|
||
|
||
## Hand-off contract
|
||
- `primary_node_ip` / `k3s_api_endpoint` (`192.168.1.50:6443`) is the value the
|
||
Ansible Cilium bootstrap and the Flux `platform/cilium` HelmRelease must BOTH use
|
||
for `k8sServiceHost` / `k8sServicePort` (docs/02 parity contract).
|
||
- Destroy/rebuild proof: name-keyed `for_each` means
|
||
`tofu destroy -target='proxmox_virtual_environment_vm.node["k3s-dev-2"]'` then
|
||
`tofu apply` re-creates just that node (`stop_on_destroy` allows the clean stop);
|
||
Ansible re-joins it; Longhorn replicas reconverge (nodes are cattle).
|
||
|
||
## Static validation (PM, offline)
|
||
```
|
||
tofu fmt -check -recursive
|
||
tofu init # fetches the bpg provider (network, read-only)
|
||
tofu validate
|
||
```
|