Minimal prod delivery, authored directly on main (= prod's live revision d1a6628)
rather than by merging work/prod-standup-campaign. That branch carries the DNS apex
inversion, which would have set prod's BASE_DOMAIN to home.keithjackson.dev; prod
runs external-dns with policy: sync and txtOwnerId: prod, so it would have begun
rewriting apex records dev owns while the non-k3s prod still serves 443 there.
Operator sequencing is "dev migrates first". Nothing under clusters/ is touched
here at all, which is what makes this safe to land now.
Also deliberately EXCLUDED, because prod's live cluster-vars has no CNPG_BACKUP_PATH
key and StrictPostBuildSubstitutions would fail the whole tier: the R2 rework of
platform/velero/ and the CNPG destinationPath changes. Those land as
feat/r2-backup-prod together with the endpoint and bucket knobs.
WHAT THIS FIXES
1. Nothing was watching CNPG. All three prod databases have run since bootstrap with
ContinuousArchiving=False and firstRecoverabilityPoint=null, and no alert existed
anywhere. Because archive_mode=on and the archive command fails, Postgres refuses
to recycle WAL: keycloak-db holds every segment ever written (257 MiB on a 5 GiB
volume, ~6.8 MiB/h) — roughly 29 days to a disk-full PANIC on the cluster's SSO
database. Found by hand, 38h late. CNPGWALArchiveBacklog would have fired within
the hour, and evaluates to 3 firing series on prod today.
Rules key on cnpg_collector_* rather than cnpg_pg_stat_archiver_*, because
nextcloud-db's exporter cannot log in to its own database (permission denied for
database "nextcloud", both clusters) so the pg_stat_archiver family does not exist
for it — the obvious metric would have been blind to one database in three.
2. No PVC outside namespace=monitoring had any fill alert. New rules are partitioned
by namespace so they cannot double-fire with MonitoringVolumeFillingUp, pinned to
job="kubelet" (the apiserver copy of the same series reports namespace=default),
and guarded by inodes > 0 — block-mode PVCs such as unifi-rootdisk report
available=0 forever and would otherwise have fired critical permanently.
3. VPNTunnelDown could never clear on a cluster with no gateway node; it has been
firing critical on prod since 2026-09-05. Now guarded so prod self-arms the moment
a node carries the egress-gateway label, with dev's behaviour unchanged today.
4. The Keycloak realm import fails OPEN: an unresolved placeholder is stored
LITERALLY and the import still reports success, then sits at Done=True and never
re-runs. On 2026-09-06 all 12 prod OIDC clients held the literal
$(env:..._CLIENT_SECRET) string and every SSO login failed, while every signal was
green. The CronJob is the level-triggered correction; two alerts make its silence
observable. Repaired live already — this stops it recurring.
Also: the R-NET-04 conformance scan descended into nested agent worktrees under
.claude/, counting 4 egress policies where 1 is authored.
Co-Authored-By: Claude Opus 5 <[email protected]>
121 lines
7.7 KiB
INI
121 lines
7.7 KiB
INI
# Static inventory — k3s node IPs ALIGNED with infra/tofu defaults (Proxmox migration IPs),
|
|
# but the SSH target is the pfSense NAT (see NAT HAND-OFF below) — this file is
|
|
# HAND-MAINTAINED and must NOT be regenerated from `tofu output -raw ansible_inventory`.
|
|
#
|
|
# k3s_primary = the --cluster-init server (embedded-etcd founder + Cilium k8sServiceHost).
|
|
# k3s_secondary = the two servers that join it. All three are servers (HA embedded etcd).
|
|
#
|
|
# NAT HAND-OFF (LIVE): the nodes live on the Proxmox LAN at 192.168.1.50/.51/.52, and
|
|
# Ansible reaches them over the pfSense NAT at 192.168.2.102, port-forwarded as
|
|
# 2250 -> 192.168.1.50:22, 2251 -> 192.168.1.51:22, 2252 -> 192.168.1.52:22.
|
|
# Therefore `ansible_host`/`ansible_port` below are the NAT SSH target (192.168.2.102:225x),
|
|
# and each node's REAL 192.168.1.x lives in the `k3s_node_ip` var — consumed for the k3s
|
|
# --node-ip, the API/tls-san endpoint (k3s_api_host), and general in-cluster parity.
|
|
# This file is HAND-MAINTAINED for the NAT: do NOT blindly regenerate it from
|
|
# tofu output -raw ansible_inventory
|
|
# because tofu has no knowledge of the pfSense NAT and would overwrite the mapping below.
|
|
|
|
[k3s_primary]
|
|
k3s-dev-1 ansible_host=192.168.2.102 ansible_port=2250 k3s_node_ip=192.168.1.50
|
|
|
|
[k3s_secondary]
|
|
k3s-dev-2 ansible_host=192.168.2.102 ansible_port=2251 k3s_node_ip=192.168.1.51
|
|
k3s-dev-3 ansible_host=192.168.2.102 ansible_port=2252 k3s_node_ip=192.168.1.52
|
|
|
|
[k3s_servers:children]
|
|
k3s_primary
|
|
k3s_secondary
|
|
|
|
# The hypervisor/workstation host itself (target of host-prereqs.yml). Its become
|
|
# (sudo) password comes from the vaulted group_vars/hypervisor/vault.yml.
|
|
[hypervisor]
|
|
localhost ansible_connection=local
|
|
|
|
# VPN egress-gateway node — runs the node-level split-tunnel OpenVPN + kill-switch
|
|
# (ADR-0015, playbooks/vpn.egress.gateway.yml). EXACTLY ONE host belongs here.
|
|
#
|
|
# THIS GROUP IS SINGLETON BY DESIGN — a second host does NOT buy failover. Cilium OSS has no
|
|
# egress-gateway failover: the CRD's own `egressGateway` description says "In case multiple nodes
|
|
# are a match for the given set of labels, the first node in lexical ordering based on their name
|
|
# will be selected". Two labelled nodes therefore means one silently wins by NAME ORDER (a labelled
|
|
# `k3s-prod-1` beats a labelled `k3s-prod-3`) and the other carries no traffic. See
|
|
# platform/vpn-egress/README.md "Resilience — there is no gateway failover" for the full statement
|
|
# and the ADR-0016 escalation. Members are already defined above with their ansible_host under
|
|
# k3s_secondary; this group only tags the egress role target.
|
|
[vpn_egress_gateway]
|
|
k3s-dev-3
|
|
|
|
# The Proxmox HYPERVISOR host itself (NOT a k3s node) — target of playbooks/backup-s3.yml,
|
|
# which stands up the off-cluster versitygw S3 backup target for Velero + CNPG. Reached DIRECTLY
|
|
# over SSH as root (its own 192.168.2.102:22, the pfSense NAT to the host's 192.168.1.9), so
|
|
# it overrides the cluster-wide `ansible_user: k3s` from group_vars/all.yml. Distinct from the
|
|
# `hypervisor` group above (that is `localhost` for host-prereqs.yml, a different machine).
|
|
[proxmox_hosts]
|
|
proxmox ansible_host=192.168.2.102 ansible_port=22 ansible_user=root ansible_ssh_private_key_file=~/.ssh/home_network
|
|
|
|
# =============================================================================
|
|
# PROD (k3s-prod-*) — Phase 7 PREP ONLY. See infra/PROD-PROVISIONING.md (T-7.1,
|
|
# especially §5/§6) and DECISIONS-LOG.md "PHASE 7 OPENED" (2026-08-31). ALL THREE
|
|
# nodes are now on the bench and SSH-reachable (`k3s-prod-1` 2026-08-31, `k3s-prod-2`
|
|
# 2026-09-01, `k3s-prod-3` 2026-09-03) — every HP EliteDesk 800 G5 Mini. This section
|
|
# is still inventory/group_vars scaffolding only: nothing beyond the read-only health
|
|
# check and the node-baseline PACKAGE floor has been run, and the operator has decided
|
|
# the CLUSTER stays uninitialized (no k3s install, no --cluster-init) until the §2 disk
|
|
# decision is closed (decision #2 in the DECISIONS-LOG entry above) — no
|
|
# single-server-etcd window, no later member-add dance.
|
|
#
|
|
# KEY DIFFERENCE FROM DEV: prod sits DIRECTLY on the 192.168.2.x LAN — no pfSense NAT
|
|
# hop in front of it. Dev's `ansible_host` above is a NAT target (192.168.2.102:225x)
|
|
# that port-forwards to the node's real 192.168.1.x address, which is why dev also
|
|
# carries a separate `k3s_node_ip` per host. Prod needs no such split: `ansible_host`
|
|
# below IS already the node's real, routable address, so `k3s_node_ip` is set to the
|
|
# SAME value (kept explicit, not omitted, only so the k3s_server role's hostvar lookup
|
|
# — see the GAP note below — has something correct to find once that gap is closed).
|
|
# `ansible_port` is the node's own real sshd (22). See group_vars/k3s_prod/vars.yml for
|
|
# the k3s_api_host override that reflects this directly-reachable topology.
|
|
#
|
|
# GROUP-NAME GAP: CLOSED 2026-08-31. The roles and playbooks used to hardcode the
|
|
# LITERAL dev groups (`hosts: k3s_servers` / `k3s_primary` / `k3s_secondary`, and
|
|
# `groups['k3s_primary']` inside roles/k3s_server + roles/cilium), so nothing could
|
|
# select the k3s_prod_* groups below. Both halves are now variables, deliberately kept
|
|
# separate because Ansible resolves them at different times:
|
|
# * WHICH HOSTS A PLAY RUNS ON — `k3s_hosts` (whole-cluster plays), `k3s_primary_hosts`
|
|
# / `k3s_secondary_hosts` (k3s.yml's split), each defaulting to the dev group.
|
|
# EXTRA VARS ONLY: a play's `hosts:` is templated before any host exists, so
|
|
# group_vars cannot be read there. Prod: `-e k3s_hosts=k3s_prod` etc.
|
|
# * WHICH GROUP A ROLE MEANS BY "MY PRIMARY"/"MY SERVERS" — `k3s_primary_group` /
|
|
# `k3s_servers_group`, set in group_vars/all.yml (dev) and group_vars/k3s_prod/vars.yml
|
|
# (prod), so roles resolve their own environment with NO per-run flag — the same
|
|
# mechanism k3s_api_host already used.
|
|
# Running with no extra vars is byte-for-byte the dev behaviour it always had. See
|
|
# infra/ansible/README.md "Targeting prod" and PROD-PROVISIONING.md §6.
|
|
# =============================================================================
|
|
|
|
[k3s_prod_primary]
|
|
k3s-prod-1 ansible_host=192.168.2.10 ansible_port=22 k3s_node_ip=192.168.2.10
|
|
|
|
[k3s_prod_secondary]
|
|
# Uncomment a node only once it is actually on the bench and answering SSH, so no play can
|
|
# target a host that isn't there. k3s-prod-2 came online 2026-09-01 (imaged identically to
|
|
# node 1: same HP EliteDesk 800 G5 Mini, same BIOS R21 02.26.00, same i7-9700T, same
|
|
# partition layout) but with a SAMSUNG PM9A1 NVMe instead of node 1's 2011 SATA C400 — the
|
|
# nodes are NOT hardware-symmetric, which matters for the §2 Longhorn/etcd disk decision.
|
|
# k3s-prod-3 came online 2026-09-03 at the planned 192.168.2.12 — same HP EliteDesk 800 G5
|
|
# Mini, same BIOS R21 02.26.00, same i7-9700T, 2x16 GB (a MATCHED Crucial pair, unlike node
|
|
# 1's mismatched Microns), full 31.1 GiB usable. It is NOT structurally immune to the §0b
|
|
# memattr bug: its firmware DOES publish a MAT (MEMATTR=0xbeece018) and the kernel merely
|
|
# happened to parse it cleanly this boot, so node 3 is on the same lottery as node 1 and
|
|
# needs the same >= 6.13 kernel. Its disk is a THIRD distinct model (WDC WDS500G2B0A SATA
|
|
# SSD) — all three nodes differ on disk, so the §2 Longhorn/etcd symmetry gap is now
|
|
# three-way, not a node-1 outlier.
|
|
# NOTE: node 3 also has wlp1s0 up on 192.168.2.195 (wifi). `ansible_host` is deliberately
|
|
# the WIRED eno1 address — the same directly-routable 192.168.2.x LAN as nodes 1 and 2.
|
|
k3s-prod-2 ansible_host=192.168.2.11 ansible_port=22 k3s_node_ip=192.168.2.11
|
|
k3s-prod-3 ansible_host=192.168.2.12 ansible_port=22 k3s_node_ip=192.168.2.12
|
|
|
|
# Umbrella group group_vars/k3s_prod/ attaches to (mirrors dev's [k3s_servers:children]
|
|
# pattern; named to match the group_vars directory so Ansible auto-loads it).
|
|
[k3s_prod:children]
|
|
k3s_prod_primary
|
|
k3s_prod_secondary
|