Files
home-network-k3s-impl/infra/ansible
jakman85andClaude Opus 5 ff0533106b
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
feat(prod): operator decisions — node DNS, service VIPs, become vault, collections path
1. PROD NODE DNS = 8.8.8.8 + 1.1.1.1 (operator, 2026-09-01). Two INDEPENDENT public
   upstreams, so the single-upstream SPOF that broke containerd pulls and CoreDNS
   forwarding on dev cannot recur. Prod deliberately differs from dev, which is
   PiHole-first because dev needs the local jackson.lan zone.

   SCOPE CORRECTED IN THE COMMENTS. Three resolvers answer *.home.keithjackson.dev and
   only one is configured here:
     pods  -> CoreDNS -> platform/coredns/coredns-custom.yaml, whose `template` plugin
              answers the WHOLE zone with ${GATEWAY_VIP}. Self-contained in-cluster;
              PiHole is NOT in this path. Prod is already correct (.20).
     LAN   -> PiHole. Not a cluster concern.
     nodes -> THIS FILE.
   The one thing public node DNS breaks: the k3s API SERVER is a node process carrying
   oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab and reaches the
   issuer through the NODE resolver, so prod would get the PUBLIC record instead of the
   prod Gateway VIP — breaking kubectl OIDC SSO. Dev avoids this only because its node
   resolver IS PiHole. Not blocking (prod has no cluster or Keycloak yet); options are
   recorded in PROD-PROVISIONING.md §4 for the operator to pick.

2. SERVICE VIPs DECIDED: UNIFI_L4_VIP 192.168.2.21, CRAFTY_L4_VIP 192.168.2.22 — the
   first two addresses after GATEWAY_VIP (.20), mirroring dev's .53/.54/.55 ordering
   within the .20-.30 pool. S3_BACKUP_ENDPOINT stays TBD-PROD-* : no prod backup target
   exists, and dev's versitygw is across the pfSense boundary on the dev host, so it is
   not DR for prod.

3. PROD BECOME VAULT committed, ansible-vault encrypted, matching the hypervisor and
   proxmox_hosts pattern. Verified working: `ansible k3s_prod -m command -a 'id -un'
   --become` returns root. The L0 no-plaintext-secrets check asserts every ansible
   vault.yml starts with $ANSIBLE_VAULT, so this stays honest by gate.

4. TOOLCHAIN GAP FIXED: the devcontainer image does NOT ship the Galaxy collections, so
   EVERY ansible play died at parse time on community.general.modprobe. ansible.cfg now
   sets collections_path = collections, and the collections are installed there from the
   existing collections/requirements.yml (gitignored — pinned by requirements, fetched at
   setup, vendored bytes are not source).

5. DRIVE HEALTH MEASURED and recorded in §2 with the raw attribute table so the numbers
   are auditable. Verdict: usable, but not for production etcd/Longhorn state — 31% of
   rated life consumed, 15 grown bad blocks, 187 erase failures, and 32 unexpected power
   losses on a consumer drive with NO power-loss protection, in a single failure domain.
   etcd is the worst possible tenant: it fsyncs constantly and unclean power loss without
   PLP is exactly how a member corrupts.

6. NON-ECC RAM: RISK ACCEPTED by the operator, recorded as a CLOSED decision, not an open
   item — do not re-litigate. Rationale: 3-node k3s homelab, not enterprise; corruption on
   one node is outweighed by the other two and causes node ejection. That holds for the
   dominant mode (etcd raft CRC + quorum; Longhorn 3 replicas + hard anti-affinity +
   rebuild). Recorded residual: corruption introduced UPSTREAM of replication replicates
   faithfully to all three replicas, so quorum sees no disagreement — low probability, and
   the only mitigation is different hardware. Follow-up noted: Longhorn's
   snapshotDataIntegrity is left at the chart default rather than set explicitly, and it
   is the mechanism that operationalises the operator's own argument.

Serves: R-DNS-01, R-DNS-03, R-ENV-02, R-ENV-03, R-SEC-01. Task: T-7.1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 00:40:29 -05:00
..

infra/ansible — k3s HA + Cilium bootstrap (Phase 1 T-1.3)

The "Cluster-join" node lifecycle stage (docs/02 stage 3) and the Flux hand-off (T-1.4 call-in). Installs a 3-server HA k3s cluster (embedded etcd), Cilium as the CNI, verifies readiness, then invokes the bootstrap/ entrypoints.

LIVE. These plays have been executed against the running dev cluster (k3s v1.36.4+k3s1, 3 nodes). This is no longer author-only scaffolding — treat it as the operational bring-up + re-provision runbook. See Re-provisioning a node below.

Serves R-ENV-01/02, R-PLAT-01, ADR-0001 (k3s), ADR-0002 (Cilium), ADR-0004 (Flux hand-off), ADR-0007 (SOPS).

Prerequisites

  1. tofu apply in ../tofu/ has created the VMs; cloud-init (../cloud-init/) has baselined them (modules, sysctls, Longhorn disk, SSH user).
  2. Inventory reflects the node IPs. inventory/hosts.ini ships aligned with the LOCKED defaults; regenerate from tofu if overridden: cd ../tofu && tofu output -raw ansible_inventory > ../ansible/inventory/hosts.ini
  3. Run from the dev container (has kubectl + helm, used by the Cilium/verify/ Flux plays via delegate_to: localhost).

Run order

cd infra/ansible
ansible-playbook playbooks/site.yml        # core bring-up, in order (baseline→k3s→cilium→verify→flux)
# — or step by step —
ansible-playbook playbooks/node-baseline.yml    # 0. packages, kernel modules, Longhorn disk, swap-off
ansible-playbook playbooks/k3s.yml         # 1. HA k3s (primary --cluster-init, then joins)
ansible-playbook playbooks/cilium.yml      # 2. Cilium CNI (cluster is NotReady until this)
ansible-playbook playbooks/verify.yml      # 3. gate #1: 3 nodes Ready, Hubble up, no kube-proxy
ansible-playbook playbooks/flux-bootstrap.yml   # 4. hand off to bootstrap/ (SOPS then Flux)

site.yml is NOT everything. It covers the core bring-up only. Two node-host layers live OUTSIDE site.yml and must be run separately:

ansible-playbook playbooks/kube-vip.yml            # control-plane VIP (192.168.1.49); defaults to k3s_servers
ansible-playbook playbooks/vpn.egress.gateway.yml  # node-host OpenVPN split-tunnel + kill-switch; defaults to k3s-dev-3

(Both take the same target-group override as the rest — see Targeting prod below.)

Re-provisioning a node (tofu -replace / fresh OS)

A node rebuild wipes everything on the host filesystem — including host-level config that Flux/Helm do NOT manage. After tofu apply/-replace re-creates a node, re-run the node-host layers or the node comes back missing pieces:

Re-run Why it doesn't survive a rebuild
node-baseline.yml packages (incl. cryptsetup), kernel modules, Longhorn disk, swap-off
k3s.yml the k3s server binary + config + join (secondaries re-join the VIP)
kube-vip.yml the kube-vip auto-deploy manifest on the server's host path
vpn.egress.gateway.yml the OpenVPN (PIA) client, tun0, systemd units + nftables kill-switch on k3s-dev-3 — miss this and egress-class=vpn pods (sonarr/radarr/qbittorrent) are kill-switch-dropped with no internet

Everything Flux owns (platform + apps) self-heals once the node re-joins; only the node-host layers above need a manual re-run. Verify the VPN after: on k3s-dev-3, systemctl is-active vpn-egress-tunnel.service and curl --interface tun0 https://ipinfo.io/ip should return a PIA exit IP (not the home ISP).

What each layer does

Playbook / role Action
k3s.yml / k3s_server k3s server install with --flannel-backend=none --disable-kube-proxy --disable-network-policy --disable=traefik,servicelb; primary --cluster-init, secondaries join via the primary's node-token (fetched, never pre-stored)
cilium.yml / cilium Gateway API CRDs, then Helm cilium with kubeProxyReplacement, k8sServiceHost/Port, gatewayAPI, egressGateway, hubble, l2announcements
verify.yml / cluster_verify waits nodes Ready + Cilium/Hubble rollout; asserts kube-proxy absent
flux-bootstrap.yml / flux_bootstrap calls bootstrap/ (SOPS age key, then flux bootstrap --path=clusters/dev)

Cilium ownership / parity contract (docs/02)

Ansible installs Cilium; the Flux platform/cilium HelmRelease adopts config afterward. The parity-critical values must match between this role's templates/cilium-values.yaml.j2 and that HelmRelease:

  • kubeProxyReplacement: true
  • k8sServiceHost: {{ k3s_api_host }} (= primary node IP, 10.73.0.11 by default)
  • k8sServicePort: 6443

The HelmRelease currently has k8sServiceHost: "REPLACE-ME" — WS-NET must set it to the same value this role uses (surfaced by tofu output primary_node_ip).

Interface assumed of bootstrap/ (authored by other workers, NOT here)

roles/flux_bootstrap invokes two entrypoints via overridable command vars:

  • flux_bootstrap_sops_cmd → bootstrap/sops/install-age-key.sh (Security Engineer, T-1.4): installs the age private key as the sops-age Secret in flux-system. Key never committed (.gitignore guards age.key/*.key).
  • flux_bootstrap_flux_cmd → bootstrap/flux/bootstrap.sh (GitOps Engineer, T-1.4): runs flux bootstrap ... --path=clusters/dev.

These script names/args are assumptions — if the bootstrap deliverables expose different entrypoints, override the two vars in roles/flux_bootstrap/defaults/main.yml; the task graph is unchanged. Flagged for PM reconciliation.

Targeting prod (R-ENV-02: same playbooks, same roles, scoped overrides)

inventory/hosts.ini carries a [k3s_prod_primary] / [k3s_prod_secondary] / [k3s_prod:children] section for the three planned bare-metal nodes (k3s-prod-1 at 192.168.2.10 exists and is reachable today; k3s-prod-2/-3 at .11/.12 are addressed but not yet racked, so their inventory lines stay commented out), with its own group_vars/k3s_prod/ (vaulted ansible_become_password + non-secret overrides).

Nothing here is forked for prod. Every node-host play takes its target group from a variable that defaults to the dev group, so the dev commands above are unchanged, and prod is the same command plus an override. Two separate mechanisms, for two separate reasons:

Layer Knob Set via Why
Which hosts a play runs on k3s_hosts, k3s_primary_hosts, k3s_secondary_hosts, vpn_egress_gateway_hosts -e extra vars only Ansible templates a play's hosts: before it has any host, so it cannot see group_vars at all
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 Host context exists by then, so each environment answers for itself with no per-run flag — same mechanism k3s_api_host already used

playbooks/node-health.yml established the first pattern (-e node_health_hosts=k3s_prod); it keeps its own var name because it is a standalone diagnostic you point anywhere, not part of the bring-up chain.

# dev — completely unchanged, no extra vars
ansible-playbook playbooks/site.yml

# prod — one flag for the whole-cluster plays
ansible-playbook playbooks/node-baseline.yml   -e k3s_hosts=k3s_prod
ansible-playbook playbooks/cilium.yml          -e k3s_hosts=k3s_prod
ansible-playbook playbooks/kube-vip.yml        -e k3s_hosts=k3s_prod
ansible-playbook playbooks/verify.yml          -e k3s_hosts=k3s_prod
ansible-playbook playbooks/flux-bootstrap.yml  -e k3s_hosts=k3s_prod   # see caveat below

# prod — k3s.yml needs both halves of the primary/secondary split
ansible-playbook playbooks/k3s.yml \
  -e k3s_primary_hosts=k3s_prod_primary -e k3s_secondary_hosts=k3s_prod_secondary

# prod — the whole chain (extra vars are global, so they reach imported playbooks)
ansible-playbook playbooks/site.yml \
  -e k3s_hosts=k3s_prod \
  -e k3s_primary_hosts=k3s_prod_primary \
  -e k3s_secondary_hosts=k3s_prod_secondary

What the roles then resolve by themselves for a prod host, from group_vars/k3s_prod/vars.yml: k3s_primary_group=k3s_prod_primary (the --cluster-init founder, the node-token delegate_to target, and the node Cilium fetches the kubeconfig from), k3s_servers_group=k3s_prod (the tls-san node-IP list), k3s_api_host=192.168.2.10, kube_vip_address=192.168.2.9, and cluster_kubeconfig_name=prod.kubeconfig — so a prod run can never overwrite the .cache/dev.kubeconfig that infra/devcontainer/dev.sh exports as $KUBECONFIG.

Packages-only baseline (--tags packages)

roles/node_baseline is tagged packages / system / disks. An untagged run is unchanged (all three, in order). --tags packages runs only the apt task:

ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages --limit k3s-prod-1

This exists to break a circular dependency: prod's disk layout (infra/PROD-PROVISIONING.md §2) is blocked on a SMART read, SMART needs smartmontools, smartmontools ships in node_baseline_packages — but the role's disk tasks need block devices that decision hasn't chosen yet. With --tags packages no disks-tagged task is selected, so the deliberate /dev/BLOCKED-pending-disk-decision-… placeholders in group_vars/k3s_prod/vars.yml are never even templated, let alone handed to mkfs. A full run against a node whose devices are still placeholders stops on an explicit assert naming §2 instead of failing obscurely at mkfs time. Never hand-install packages on a node — that is this role's job (operator ruling, 2026-08-31).

Still not runnable end to end

  • The disk-layout (§2) and the node_baseline_*_device overrides remain BLOCKED placeholders.
  • group_vars/k3s_prod/vault.yml (the become password) does not exist yet, so no prod play can escalate — and --check against prod fails for the same reason.
  • flux-bootstrap.yml retargets, but 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 (§7).
  • vpn.egress.gateway.yml is parameterised (-e vpn_egress_gateway_hosts=…) but prod has no egress group yet — whether prod runs one at all is an open §6 decision, and it would need its own vaulted PIA credentials.
  • The operator has ruled the prod cluster stays uninitialized until all three nodes are present (§8). 192.168.2.20 is prod's ingress Gateway VIP — a Flux/cluster-vars concern, not an Ansible one; the kube-vip control-plane VIP is 192.168.2.9 (kube_vip_address).

Everything above this section describes the dev runbook; the values in it (192.168.1.x, .cache/dev.kubeconfig, clusters/dev) are dev's, supplied by group_vars, not baked into the roles.

Static validation (PM, in dev container — NOT run here)

yamllint -c ../../ci/yamllint.yaml infra/ansible
ansible-playbook -i inventory/hosts.ini --syntax-check playbooks/site.yml
ansible-lint playbooks/ roles/          # if the profile is enforced for infra/