roles/k3s_server, roles/cilium and every node-host playbook hardcoded the dev group
names k3s_primary/k3s_secondary/k3s_servers, so the prod inventory groups could not be
selected at all. That broke the R-ENV-02 promise that prod reuses the SAME roles, and
blocked PROD-PROVISIONING.md §6 outright.
TWO KNOBS, deliberately separate — Ansible templates a play's `hosts:` BEFORE any host
context exists, so group_vars is unreadable there, while roles run WITH host context:
* which hosts a play RUNS ON — k3s_hosts, k3s_primary_hosts/k3s_secondary_hosts
(k3s.yml's split), vpn_egress_gateway_hosts; each `| default(<dev group>)`, set by -e
only. Follows node-health.yml's established `<name>_hosts | default(dev)` pattern.
* which group a ROLE means by "my primary"/"my servers" — k3s_primary_group,
k3s_servers_group in group_vars/all.yml (dev), overridden in group_vars/k3s_prod.
Follows the k3s_api_host convention already in this repo. No per-run flag.
DEV IS UNCHANGED — the key evidence. --list-hosts/--list-tasks/--syntax-check captured
for all 9 playbooks at HEAD and after: sorted host lists IDENTICAL for all 9, same
primary/secondary split, same 6-play site.yml. (Raw --list-hosts PRINT ORDER varies run
to run; proven to be Ansible's own set ordering by running UNMODIFIED HEAD cilium.yml
four times -> 3,1,2 / 1,3,2 / 1,2,3 / 1,2,3. Hence sorted comparison.) --list-tasks
differs in exactly two files, only by added TAGS annotations and one new assert; no task
reordered, renamed or removed. Var resolution verified per environment with -c local:
dev -> k3s_primary/192.168.1.50/sans .50,.49,127.0.0.1,.51,.52; prod ->
k3s_prod_primary/192.168.2.10/vip .9/sans .10,.9,127.0.0.1,.11,.12.
BREAKS THE SMARTMONTOOLS CIRCULAR DEPENDENCY. node_baseline tasks are now tagged
packages/system/disks, so the package install can run alone:
ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages
Skipping is the guard — no disks-tagged task is selected, so the
BLOCKED-pending-disk-decision paths are never templated, let alone passed to mkfs.
Belt-and-braces, a new disks-tagged assert fires first in a FULL run and names §2
explicitly instead of failing obscurely at mkfs. `tags: never` was deliberately NOT used
(it would change the default run).
FOOTGUN FOUND AND FIXED: roles/cilium, cluster_verify and flux_bootstrap all hardcoded
.cache/dev.kubeconfig — the exact file infra/devcontainer/dev.sh exports as $KUBECONFIG.
Once prod became targetable, a prod cilium/verify run would have SILENTLY OVERWRITTEN it,
leaving the operator's kubectl quietly answering from PROD. Now cluster_kubeconfig_name
(dev.kubeconfig -> prod.kubeconfig). Dev path byte-identical.
KNOWN, DOCUMENTED, NOT FIXED HERE: flux-bootstrap.yml retargets but roles/flux_bootstrap
shells out to bootstrap/flux/bootstrap.sh with a hardcoded --path=clusters/dev — a
bootstrap/ subtree change, not a target-group change (§6, cross-referenced to §7).
vpn.egress.gateway.yml is parameterised but prod has no egress group yet and would need
its own vaulted PIA credentials. --check against prod is still impossible until the prod
vault exists — even the packages-only run needs it.
Nothing was run against the dev cluster: --list-hosts/--list-tasks/--syntax-check, plus
debug/assert with -c local, only.
Serves: R-ENV-01, R-ENV-02, R-ENV-03. Task: T-7.1.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
281 lines
17 KiB
Markdown
281 lines
17 KiB
Markdown
# Prod bare-metal provisioning checklist (T-7.1)
|
||
|
||
**Task:** T-7.1 · **Workstream:** WS-INFRA · **Serves:** R-ENV-01, R-ENV-02, R-ENV-03, R-PLAT-01/03
|
||
**Status:** Phase 7 **opened by the operator 2026-08-31** — *prep only*. Nothing is installed on prod
|
||
hardware until the blockers in §7 are closed and `make -C ci gate` is green.
|
||
|
||
> **Entry-gate note (recorded, not waived).** `roadmap/phases.md` gates Phase 7 on *"Phases 1–6
|
||
> proven in dev/test; operator declares prod hardware ready."* The operator has declared hardware
|
||
> ready. The first condition is **not** met: conformance is 37/51, and the uncovered set includes
|
||
> **R-ENV-01/02/03** — the very requirements this document serves. We proceed with prep work
|
||
> knowingly; see `DECISIONS-LOG.md` (2026-08-31 Phase-7 entry).
|
||
|
||
Prod is **3× mini-PC bare metal** ([spec docs/02](../../home-network-k3s/docs/02-environments-and-provisioning.md)).
|
||
The dev path (OpenTofu → Proxmox VM → cloud-init) **does not apply**: there is no hypervisor API to
|
||
call. Stage 1 "Create" is a human imaging a box. Stages 2–4 (baseline, cluster-join, manage) reuse
|
||
the *same* Ansible roles and the *same* Flux base as dev — that reuse is the R-ENV-02 obligation.
|
||
|
||
---
|
||
|
||
## 0. Node inventory
|
||
|
||
Fill a row per node as it lands. Node 1 is as-found on 2026-08-31.
|
||
|
||
| | node 1 | node 2 | node 3 |
|
||
|---|---|---|---|
|
||
| Hostname | `k3s-prod-1` | `k3s-prod-2` | `k3s-prod-3` |
|
||
| LAN IP | `192.168.2.10` | `192.168.2.11` | `192.168.2.12` |
|
||
| Model | HP EliteDesk 800 G5 Desktop Mini | | |
|
||
| CPU | i7-9700T, 8c/8t, VT-x | | |
|
||
| RAM | 31 GiB | | |
|
||
| Disks | 1× 477 GB SATA SSD (`C400-MTFDDAC512M`) | | |
|
||
| OS | Debian 13 (trixie), kernel 6.12 | | |
|
||
| Firmware | HP R21 Ver. 02.26.00 | | |
|
||
| `/dev/kvm` | present | | |
|
||
|
||
Dev nodes for comparison: 8 vCPU / 24 GiB / Debian 13, **two** dedicated data disks (Longhorn + etcd).
|
||
|
||
---
|
||
|
||
## 1. Firmware / BIOS (per node, before OS install)
|
||
|
||
- [ ] **Virtualization enabled** — VT-x *and* VT-d. Required for KubeVirt (R-PLAT-03); verify after
|
||
boot with `/dev/kvm` present and `grep -cE 'vmx|svm' /proc/cpuinfo` non-zero.
|
||
- [ ] **AC power recovery = Power On.** A homelab node must return after a power cut without a
|
||
human pressing the button. (`docs/02` calls out wake/boot behaviour explicitly.)
|
||
- [ ] **Wake-on-LAN enabled** on the onboard NIC (remote recovery when the box is off).
|
||
- [ ] **Boot order** — internal disk first; USB above it only while imaging. Disable network boot
|
||
unless PXE is adopted.
|
||
- [ ] **Secure Boot** — record the setting. k3s itself is fine either way; note it so all three
|
||
nodes match.
|
||
- [ ] **Firmware version recorded** in §0 and consistent across all three nodes.
|
||
|
||
## 2. Disk layout — **OPEN DECISION, blocks the build**
|
||
|
||
`docs/02` requires the OS-vs-Longhorn device split to be **documented before prod build**. Dev went
|
||
further and added a *third* device for etcd after HDD contention starved etcd WAL fsync
|
||
(`docs/plans/ssd-etcd-isolation.md`).
|
||
|
||
Node 1 as-found is a **single** 477 GB SATA SSD carrying everything:
|
||
|
||
```
|
||
sda 477G C400-MTFDDAC512M (SSD, ~2011-era Micron/Crucial)
|
||
├─sda1 976M /boot/efi
|
||
├─sda2 30G /
|
||
├─sda3 12G /var
|
||
├─sda4 977M swap
|
||
└─sda5 433G /srv <- the only large free space
|
||
```
|
||
|
||
Consequences to settle before imaging nodes 2 and 3:
|
||
|
||
- [ ] **Drive health checked first** (operator decision, 2026-08-31). See §5 for the command —
|
||
needs root. A ~15-year-old consumer SSD is not an obvious place for prod state.
|
||
- [ ] **Decide the layout.** `roles/node_baseline` expects *block devices*
|
||
(`node_baseline_longhorn_device`, `node_baseline_etcd_device`) and makes a filesystem on them.
|
||
Its defaults `/dev/vdb` and `/dev/vdc` are **virtio names that do not exist on bare metal** —
|
||
prod must override them in `group_vars`. On a single-disk node there is no second device at
|
||
all, so one of:
|
||
- **Add a second drive** (M.2 NVMe if the chassis has a free slot; verify physically — only a
|
||
SATA controller is populated today). Cleanest: matches dev's isolation, gives etcd its own
|
||
spindle.
|
||
- **Use `/dev/sda5`** (the 433 GB `/srv` partition) as the Longhorn device, etcd stays on `/`.
|
||
Buildable today, zero spend, but re-creates the shared-spindle contention dev was burned by.
|
||
- **Repartition** into dedicated OS / etcd / Longhorn partitions. Better isolation, still one
|
||
physical failure domain.
|
||
- [ ] **All three nodes get the identical layout.** Longhorn replica placement assumes symmetry.
|
||
- [ ] Record the chosen device paths here and set them in
|
||
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` (they are deliberately
|
||
`/dev/BLOCKED-pending-disk-decision-…` placeholders today — do **not** guess). A full
|
||
`node-baseline` run against a node still carrying those placeholders now stops on an explicit
|
||
assert naming this section, instead of failing obscurely at `mkfs` time.
|
||
- [x] **The SMART-vs-smartmontools circular dependency is broken** (2026-08-31). `roles/node_baseline`
|
||
is tagged `packages` / `system` / `disks`, so the package floor can land **without** the disk
|
||
tasks this decision blocks — see §5's drive-health box for the exact command.
|
||
|
||
## 3. OS install (per node)
|
||
|
||
- [ ] **Debian 13 (trixie)**, matching dev exactly — same kernel series, same package baseline.
|
||
- [ ] Hostname `k3s-prod-N`.
|
||
- [ ] Partition per the §2 decision.
|
||
- [ ] **No cloud-init needed.** Dev's first-boot work was deliberately moved into
|
||
`roles/node_baseline` when provisioning became API-only (`infra/cloud-init/` is a README now),
|
||
so the same role covers bare metal. Nothing to port.
|
||
|
||
## 4. Network (per node)
|
||
|
||
- [ ] **Static IP** on the prod LAN, recorded in §0. Node 1 is `192.168.2.10/24`, gateway
|
||
`192.168.2.1`.
|
||
- [x] **Prod address plan — DECIDED (operator, 2026-08-31).**
|
||
|
||
| Role | Address | Owned by |
|
||
|---|---|---|
|
||
| `k3s-prod-1` / `-2` / `-3` | `192.168.2.10` / `.11` / `.12` | Ansible inventory |
|
||
| **kube-vip control-plane VIP** | `192.168.2.9` | Ansible (`group_vars/k3s_prod`) |
|
||
| **Ingress Gateway VIP** (`GATEWAY_VIP`) | `192.168.2.20` | Flux (`clusters/prod`) |
|
||
| **Cilium L2 announcement pool** | `192.168.2.20` – `.30` | Flux (`platform-config/cilium`) |
|
||
|
||
Two deliberate properties, both mirroring dev:
|
||
- The **control-plane VIP sits outside the LB pool** (`.9`, below the nodes), so kube-vip and
|
||
Cilium LB-IPAM can never contend for the same address. Dev achieves this the other way
|
||
round (`.49` control-plane, pool `.53-.60`).
|
||
- **`GATEWAY_VIP` is the FIRST address of the pool.** Cilium LB-IPAM can only allocate from a
|
||
defined pool block, so a Gateway VIP outside every pool would not be allocatable — the pool
|
||
was widened from `.21-.30` to `.20-.30` for exactly this reason. Dev has the same shape
|
||
(`GATEWAY_VIP` `.53` = first of `.53-.60`).
|
||
- [ ] **Reserve all of `192.168.2.9` and `.20-.30` outside any DHCP range** on the prod LAN before
|
||
the build, and confirm nothing else on the network already answers on them.
|
||
- [ ] **Resolver: configure at least two upstreams.** Node 1 currently has a single IPv4 nameserver
|
||
(`192.168.2.1`). A single upstream is a known SPOF that has already bitten this project —
|
||
it broke containerd image pulls and CoreDNS forwarding on dev. A fix exists but is **stranded
|
||
unmerged** on `fix/node-dns-resilient-upstream`; land it, then apply the same pattern here.
|
||
- [ ] Confirm the prod LAN's relationship to the dev LAN (`192.168.1.x`) and to the pfSense NAT —
|
||
prod on `192.168.2.x` sits on the *workstation* side of the boundary dev lives behind.
|
||
|
||
## 5. Access (per node) — **blocks all Ansible**
|
||
|
||
- [ ] **`k3s` user with the `home_network` SSH key.** Working on node 1.
|
||
- [ ] **Password-protected sudo, driven by Ansible `become` + a vaulted password.**
|
||
**Prod does NOT use NOPASSWD sudo** (operator decision, 2026-08-31). Dev nodes do; prod
|
||
deliberately keeps the sudo password in place and supplies it to Ansible as an
|
||
**ansible-vault-encrypted** var, exactly like the existing `hypervisor` and `proxmox_hosts`
|
||
groups:
|
||
```
|
||
inventory/group_vars/k3s_prod/
|
||
README.md how to (re)create the vault — committed
|
||
vault.yml ansible_become_password: "..." — committed, ENCRYPTED
|
||
```
|
||
The passphrase that decrypts it lives **outside the repo** at
|
||
`~/.config/home-k3s/ansible-vault-pass` (`ansible.cfg :: vault_password_file`), so plays still
|
||
run unattended without a plaintext secret ever touching git.
|
||
```bash
|
||
ansible-vault create inventory/group_vars/k3s_prod/vault.yml # ansible_become_password: ...
|
||
```
|
||
Prod nodes must therefore set `ansible_become: true` in their group_vars and must **not**
|
||
inherit any passwordless-sudo assumption from `group_vars/all.yml`.
|
||
- [ ] **Drive health baseline** (§2). **Do not hand-install the tooling** — package installation is
|
||
the `node_baseline` role's job, never a manual `apt-get` on a node (operator ruling,
|
||
2026-08-31; same click-ops-never principle as the rest of the repo). `smartmontools` is part
|
||
of `node_baseline_packages`, so it arrives with the baseline play — but a *full* baseline
|
||
would hit the disk tasks §2 has not decided yet. Run the **packages-only** baseline instead
|
||
(the `disks`-tagged tasks, including the BLOCKED-device assert, are not selected at all, so
|
||
the placeholder device paths are never even templated):
|
||
```bash
|
||
cd infra/ansible
|
||
ansible-playbook playbooks/node-baseline.yml \
|
||
-e k3s_hosts=k3s_prod --tags packages --limit k3s-prod-1
|
||
```
|
||
(Needs `group_vars/k3s_prod/vault.yml` to exist first — the become password above.) Reading
|
||
the drive is then:
|
||
```bash
|
||
sudo smartctl -a /dev/sda # Power_On_Hours, Percent_Lifetime_Remaining, reallocated sectors
|
||
```
|
||
Record the result in §2 and close the layout decision in the same change that replaces the
|
||
two `node_baseline_*_device` placeholders.
|
||
|
||
## 6. Ansible baseline & cluster join
|
||
|
||
Reuses the dev roles **unchanged** — that reuse **is** R-ENV-02. Only inventory and group_vars
|
||
differ, plus a target-group override on the command line.
|
||
|
||
### How prod is targeted (done, 2026-08-31)
|
||
|
||
Every node-host play used to hardcode the literal dev groups `k3s_servers` / `k3s_primary` /
|
||
`k3s_secondary`, so nothing here could select prod at all. Two knobs now close that, deliberately
|
||
kept separate because Ansible resolves them at different times:
|
||
|
||
| Layer | Knob | Set via |
|
||
|---|---|---|
|
||
| Which hosts a play **runs on** | `k3s_hosts`, `k3s_primary_hosts`, `k3s_secondary_hosts` (each defaults to the dev group) | `-e` **extra vars only** — a play's `hosts:` is templated before any host exists, so `group_vars` is not readable there |
|
||
| Which group a **role** means by "my primary" / "my servers" | `k3s_primary_group`, `k3s_servers_group`, `cluster_kubeconfig_name` | `group_vars` — dev in `all.yml`, prod in `k3s_prod/vars.yml`; **no per-run flag** |
|
||
|
||
So `roles/k3s_server` finds the prod `--cluster-init` founder, builds the prod join target and
|
||
tls-san list, and `roles/cilium` fetches the prod kubeconfig, with **no role fork**. Running with
|
||
no extra vars is byte-for-byte the dev behaviour it always had (verified by a before/after
|
||
`--list-hosts` diff on every playbook). Full detail: `infra/ansible/README.md` → **Targeting prod**.
|
||
|
||
- [x] Add a `k3s_prod` inventory group (`k3s_prod_primary` / `k3s_prod_secondary`). Prod is reached
|
||
**directly** on the LAN — no NAT port-forward hop, unlike dev's `192.168.2.102:225x` mapping.
|
||
(Nodes 2/3 stay commented out until they are physically on the bench.)
|
||
- [x] `inventory/group_vars/k3s_prod/vars.yml`: `kube_vip_address` (`192.168.2.9`),
|
||
`cluster_controller_api_server`, `k3s_api_host`, the group-name overrides above — **and** the
|
||
§2 device overrides for `node_baseline_longhorn_device` / `node_baseline_etcd_device`, which
|
||
are still BLOCKED placeholders.
|
||
- [ ] `inventory/group_vars/k3s_prod/vault.yml` — the vaulted `ansible_become_password` (§5). Until
|
||
it exists **no** prod play can escalate, and `--check` against prod cannot work either.
|
||
- [ ] Run the **packages-only** baseline first, to unblock the §2 drive-health read:
|
||
```bash
|
||
ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages
|
||
```
|
||
- [ ] Run the **full** `playbooks/node-baseline.yml -e k3s_hosts=k3s_prod` → packages, kernel
|
||
modules, sysctls, iscsid, data-disk filesystems + mounts. **Blocked until §2 is decided**: it
|
||
asserts on the placeholder device paths and stops. (`qemu-guest-agent` is a no-op on bare
|
||
metal — the role already only *starts* it when the virtio channel exists, so it degrades
|
||
cleanly.)
|
||
- [ ] Run `playbooks/k3s.yml -e k3s_primary_hosts=k3s_prod_primary -e k3s_secondary_hosts=k3s_prod_secondary`
|
||
→ HA embedded etcd, flannel/kube-proxy/Traefik/servicelb disabled. **Only once all three
|
||
nodes are present** — see §8.
|
||
- [ ] Run `playbooks/kube-vip.yml -e k3s_hosts=k3s_prod` → control-plane VIP (`192.168.2.9`) on the
|
||
prod LAN.
|
||
- [ ] Run `playbooks/cilium.yml -e k3s_hosts=k3s_prod` → CNI at bootstrap; Flux's HelmRelease adopts
|
||
it afterwards. The bootstrap values and the HelmRelease **must stay in parity**
|
||
(`kubeProxyReplacement`, `k8sServiceHost`/`Port`) or the two layers fight over CNI config
|
||
(`docs/02`). Note the prod kubeconfig is fetched to `.cache/prod.kubeconfig`, never over the
|
||
dev one the dev container exports as `$KUBECONFIG`.
|
||
- [ ] Run `playbooks/verify.yml -e k3s_hosts=k3s_prod`.
|
||
- [ ] `playbooks/flux-bootstrap.yml -e k3s_hosts=k3s_prod` **retargets but is not yet correct**:
|
||
`roles/flux_bootstrap` shells out to `bootstrap/flux/bootstrap.sh`, which bootstraps
|
||
`--path=clusters/dev`. Prod needs `--path=clusters/prod` — a `bootstrap/` change, not a
|
||
target-group change. Tracked with the other repo blockers in §7.
|
||
- [ ] Decide whether prod runs a VPN egress gateway node (dev uses `k3s-dev-3`, ADR-0015).
|
||
`playbooks/vpn.egress.gateway.yml` takes `-e vpn_egress_gateway_hosts=<group>`, but there is
|
||
deliberately no prod egress group in the inventory yet, and it would need its **own** vaulted
|
||
PIA credentials — the existing vault is scoped to the dev group.
|
||
|
||
## 7. Repo blockers — **prod cannot be reconciled until these land**
|
||
|
||
`clusters/prod` is design-only today and **would build wrong**. R-ENV-02 promises dev and prod differ
|
||
*only* by `cluster-vars` values; in practice dev LAN addresses are hardcoded into base manifests:
|
||
|
||
| Hardcoded value | Where | Prod needs |
|
||
|---|---|---|
|
||
| `k8sServiceHost: 192.168.1.50` | `platform/cilium/helmrelease.yaml:50`, mirrored in `values.yaml:16` | prod primary node IP |
|
||
| LB pool `192.168.1.53–.60` | `platform-config/cilium/l2-ip-pool.yaml:15` | a prod-LAN pool |
|
||
| `192.168.1.55` (crafty) | `platform/crafty/service-l4.yaml` | prod-LAN address |
|
||
| `192.168.1.54` (UniFi VM) | `apps/vms/unifi/50-service-l4.yaml` | prod-LAN address |
|
||
| `192.168.1.9:9000` (S3 backup) | `platform/keycloak/postgres-cluster.yaml`, `apps/wave-0/{nextcloud,semaphore}-db.yaml` | reachable prod backup target |
|
||
|
||
- [ ] **Tokenize each into a `cluster-vars` knob** with a base default, then set the value in *both*
|
||
overlays. Adding a knob before referencing `${KNOB}` is the sanctioned mechanism (see the
|
||
header comment in `clusters/base/cluster-vars.yaml`). `platform`, `platform-config`, the app
|
||
waves, `apps-vms` and `crafty` all already carry `postBuild.substituteFrom: cluster-vars`, so
|
||
the seam exists.
|
||
- [ ] **`clusters/prod` must set `GATEWAY_VIP`.** It currently doesn't, so prod would inherit the
|
||
base default `10.73.0.240` — an address from the **retired libvirt network**. The base file's
|
||
own comment flags this as owed "when hardware is built."
|
||
- [ ] **Dev overlay sets every new knob to today's exact value** so rendered dev output is
|
||
byte-identical and the live cluster sees zero drift. Verify with `make -C ci render-golden`.
|
||
- [ ] Confirm prod's backup target: dev's S3 (`versitygw` on the Proxmox host, `192.168.1.9`) is on
|
||
the dev side of the network. Off-cluster backup is DR-critical (R-STATE-03).
|
||
|
||
## 8. Build sequence (operator decision, 2026-08-31: **wait for all three nodes**)
|
||
|
||
Node 1 is baselined now; the cluster is **not** initialized until nodes 2 and 3 are on the bench.
|
||
This keeps prod spec-conformant (3-node HA embedded etcd) from its first boot, avoids a
|
||
single-server-etcd window, and avoids a later member-add dance.
|
||
|
||
1. Per-node §1–§5 on each of the three boxes.
|
||
2. §7 repo work lands on `main` with the gate green.
|
||
3. `node-baseline` → `k3s` (all three) → `kube-vip` → `cilium`.
|
||
4. `flux bootstrap ... --path=clusters/prod`.
|
||
5. Conformance against prod (§9).
|
||
|
||
## 9. Exit criteria (Phase 7)
|
||
|
||
- [ ] Prod runs the same config as dev via the prod overlay, **no base edits** (R-ENV-02).
|
||
- [ ] Node destroy/rebuild from IaC + Ansible without data loss (R-ENV-01, R-STATE-01).
|
||
- [ ] Full **automated** prod DR drill (R-STATE-03) — `definition-of-done.md` requires this for prod
|
||
specifically.
|
||
- [ ] Monitoring and alerts live on prod.
|
||
- [ ] The conformance suite passes green **against the prod cluster**, not just dev (R-TEST-03).
|