Merge pull request 'Phase 7 (prod) opened — prep only: T-7.1 checklist, R-ENV-02 tokenization, node_health, CI false-green fix' (#4) from docs/phase-7-open-prod-node-1 into main
This commit is contained in:
commit
dce0e8ffe9
84 files changed
+7256
-210
No files matched your search
@@ -1250,3 +1250,249 @@ fresh agent should read this entry + `infra/PHASE1-STATUS.md` + `docs/plans/` an
|
||||
4. **Remaining Phase-6 onboarding** — see DESIGN-waves.md / `catalog/` (services rendered-but-not-onboarded).
|
||||
5. **Vendor platform/kubevirt** remote bases proactively (same flake class as #8).
|
||||
6. **Human action**: finish Headlamp login = complete TOTP enrollment in the browser.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-31 — **PHASE 7 OPENED (prep only)**: first prod node on the bench
|
||||
|
||||
The operator added the first **production** node and opened roadmap Phase 7. This entry records the
|
||||
opening, the state of the hardware, the operator's three decisions, and the blockers that stand
|
||||
between "a node exists" and "prod reconciles from `clusters/prod`".
|
||||
|
||||
### Governance — Phase 7 entry gate, recorded not waived
|
||||
|
||||
`roadmap/phases.md` gates Phase 7 on **two** conditions: *"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 exact
|
||||
requirements prod provisioning serves. We proceed with *prep only* on that basis, with no prod
|
||||
install until the §7 repo blockers close and the gate is green. This is an accepted, explicit risk,
|
||||
not an oversight.
|
||||
|
||||
### Node 1 as-found (`192.168.2.10`, verified by SSH 2026-08-31)
|
||||
|
||||
Not blank — imaged and reachable. **HP EliteDesk 800 G5 Desktop Mini** (matches the spec's `3× mini-PC
|
||||
bare metal`), i7-9700T 8c/8t, **31 GiB RAM**, Debian 13 trixie / kernel 6.12, hostname already
|
||||
`k3s-prod-1`, `k3s` user reachable with the `home_network` key, VT-x + `/dev/kvm` present
|
||||
(KubeVirt-capable), k3s not installed. Firmware HP R21 Ver. 02.26.00.
|
||||
|
||||
**One 477 GB SATA SSD** (`C400-MTFDDAC512M` — a ~2011-era Micron/Crucial consumer drive) carrying
|
||||
everything: `/` 30G, `/var` 12G, swap, and `/srv` 433G. No second device. Dev, by contrast, runs two
|
||||
dedicated data disks per node (Longhorn + a dedicated SSD for etcd, added after HDD contention
|
||||
starved etcd WAL fsync).
|
||||
|
||||
### Operator decisions (2026-08-31)
|
||||
|
||||
1. **Phase 7 opens, prep work only** — repo work and node baselining now; no k3s install until the
|
||||
parameterization blockers land and the gate is green.
|
||||
2. **Wait for all three nodes before `cluster-init`** — prod comes up as spec-conformant 3-node HA
|
||||
embedded etcd on day one; no single-server-etcd window, no later member-add.
|
||||
3. **Check drive health before choosing the disk layout** — a 15-year-old consumer SSD is not an
|
||||
obvious home for prod state.
|
||||
|
||||
### Findings — prod would build WRONG today
|
||||
|
||||
1. **R-ENV-02 is violated in the base (the headline blocker).** The overlay model promises dev and
|
||||
prod differ *only* by `cluster-vars` values. In fact dev LAN addresses are hardcoded into base
|
||||
manifests: `k8sServiceHost: 192.168.1.50` (`platform/cilium/helmrelease.yaml:50` + the
|
||||
`values.yaml` mirror), the Cilium LB pool `192.168.1.53-.60`
|
||||
(`platform-config/cilium/l2-ip-pool.yaml`), crafty `.55`, the UniFi VM `.54`, and three S3 backup
|
||||
endpoints at `192.168.1.9:9000`. **Prod is on `192.168.2.x` — none of these follow.** Fix is to
|
||||
tokenize each into a `cluster-vars` knob and set it in both overlays; the `postBuild.substituteFrom`
|
||||
seam already exists on every relevant Kustomization. Note `platform/cilium/values.yaml` is only a
|
||||
*reference mirror* — the authoritative pair is the HelmRelease (Flux) and the Ansible j2 template
|
||||
(already parameterized), so the Ansible side needs no change.
|
||||
2. **`clusters/prod` never sets `GATEWAY_VIP`** → it would inherit the base default `10.73.0.240`,
|
||||
an address on the **retired libvirt network**. `cluster-vars`' own comment flags this as owed
|
||||
"when hardware is built".
|
||||
3. **Sudo on node 1 requires a password** — every Ansible playbook fails at its first `become`.
|
||||
**Operator ruling: prod does NOT get NOPASSWD sudo** (my initial recommendation to mirror dev was
|
||||
rejected, correctly — dev's passwordless sudo is a convenience that should not propagate to
|
||||
prod). Instead prod uses Ansible `become` with an **ansible-vault-encrypted**
|
||||
`ansible_become_password` in `inventory/group_vars/k3s_prod/vault.yml`, mirroring the existing
|
||||
`hypervisor` and `proxmox_hosts` groups; the decrypting passphrase stays outside the repo at
|
||||
`~/.config/home-k3s/ansible-vault-pass`. Plays still run unattended, no plaintext secret in git,
|
||||
and prod keeps a real sudo boundary.
|
||||
4. **`node_baseline` device defaults are virtio** (`/dev/vdb`, `/dev/vdc`) and do not exist on bare
|
||||
metal; the role makes a filesystem on a *block device*, so a single-disk node needs either a
|
||||
second drive, `/dev/sda5`, or a repartition. Prod `group_vars` must override.
|
||||
5. **Single upstream resolver on node 1** (`192.168.2.1`) — the same SPOF class that broke
|
||||
containerd pulls and CoreDNS forwarding on dev. The fix is **stranded unmerged** on
|
||||
`fix/node-dns-resilient-upstream`.
|
||||
|
||||
### RESOLVED BY OPERATOR — status comes from the repository, not the task board
|
||||
|
||||
I raised an escalation here: `AGENTS.md` §11 designates `agents/task-board.md` as the one surface
|
||||
agents may edit, yet `ci/spec-immutability.sh` fails the merge gate on **any** spec-repo dirt with no
|
||||
path exception, and committing the board there moves spec `HEAD` off `ci/SPEC_PIN` — which also
|
||||
fails. Editing the board as instructed blocks every merge. That is why the board still reads
|
||||
all-`todo` after six delivered phases.
|
||||
|
||||
**Operator ruling (2026-08-31): the task board is not the status surface. The repository is.**
|
||||
Status of each requirement/spec is to be *derived from the state of the repo* — what is authored,
|
||||
what is reconciled, what has a green requirement-tagged test — not from a hand-maintained board.
|
||||
|
||||
Consequences, adopted:
|
||||
- The spec board is left alone. It is a historical seed, not a live surface. No agent edits it, and
|
||||
no `SPEC_PIN` churn is spent keeping it current.
|
||||
- `ci/spec-immutability.sh` keeps its full strictness — **no path exception was added**. Weakening
|
||||
an R-TEST-06 gate to make a bookkeeping file writable would have been a defect, not a fix.
|
||||
- The **repo-derived spec status report** becomes the artifact of record, alongside the existing
|
||||
requirement-coverage report and this log. Coverage already answers "does a tagged test exist";
|
||||
the status view must also answer "is this requirement actually *implemented and reconciled* in
|
||||
the repo as it stands".
|
||||
|
||||
### Next steps
|
||||
|
||||
1. **Operator:** configure NOPASSWD sudo on `k3s-prod-1`, then capture `smartctl -a /dev/sda`
|
||||
(commands in `infra/PROD-PROVISIONING.md` §5). The disk decision waits on that output.
|
||||
2. **Land the R-ENV-02 tokenization** — the critical path for prod reconciling at all. Dev overlay
|
||||
must set every new knob to today's exact value so live dev sees zero drift (`make -C ci render-golden`).
|
||||
3. **Complete `clusters/prod`** — `GATEWAY_VIP`, the L2 pool, and the prod backup target.
|
||||
4. **Land the two stranded fix branches** (`fix/node-dns-resilient-upstream`,
|
||||
`fix/act-runner-dind-readiness`) — the DNS one now matters for prod too.
|
||||
5. Resolve the task-board escalation above.
|
||||
6. Nodes 2 and 3 through `infra/PROD-PROVISIONING.md` §1–§5 as they arrive.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-31 — CI is the enforcement point; the LOCAL gate produced a FALSE GREEN
|
||||
|
||||
**Operator directive: "Conformance and gates are enforced at the gitea runner and CI/CD level.
|
||||
Use it."** Adopted. The authority on whether a change is mergeable is the Gitea Actions run, not a
|
||||
local `make -C ci gate`. Local runs are a fast pre-check only.
|
||||
|
||||
This was proven immediately and expensively. Branch `docs/phase-7-open-prod-node-1`:
|
||||
|
||||
- **Local** `make -C ci gate` → `no changed files vs base; empty touched-requirement set` →
|
||||
**MERGE GATE: PASS**.
|
||||
- **CI**, same commit (`a1fb31a`, task 200) → 17 touched requirements correctly computed →
|
||||
`REQUIRE: FAILED — required requirement(s) uncovered: ['R-SEC-01','R-SEC-02','R-TEST-06']` →
|
||||
**MERGE GATE: BLOCKED**.
|
||||
|
||||
The local run resolved an empty diff base and therefore checked nothing, reporting green. **A local
|
||||
false green is the most dangerous failure mode this project has** — it hid real coverage debt from
|
||||
the PM and would have hidden it from any future agent. `ci/touched-requirements.sh` base-ref
|
||||
resolution is being fixed so local agrees with CI, or announces loudly that it cannot determine a
|
||||
base. An honest noisy skip beats a quiet false pass (`ci/README.md` exit-code contract).
|
||||
|
||||
The gate itself was RIGHT: we authored a vaulted-secret flow (R-SEC-01) and status tooling that
|
||||
reasons about spec immutability (R-TEST-06) while those requirements had **no covering test at
|
||||
all**. Tests are being authored rather than the rule being narrowed.
|
||||
|
||||
**How to read CI from a workstation** (the API paths are non-obvious, so recording them):
|
||||
```bash
|
||||
CRED=$(printf 'protocol=https\nhost=gitea.home.keithjackson.dev\n\n' | git credential fill)
|
||||
# list recent runs (id | workflow | branch | status | sha):
|
||||
curl -sk -u "$U:$P" "$B/api/v1/repos/Home-Network/home-network-k3s-impl/actions/tasks?limit=10"
|
||||
# fetch a task's log — NOTE the job-id/task-id OFFSET: task N is at jobs/(N+5)/logs on this instance
|
||||
curl -sk -u "$U:$P" "$B/api/v1/repos/Home-Network/home-network-k3s-impl/actions/jobs/205/logs"
|
||||
```
|
||||
`.../actions/runs/<run>/jobs` is 404 on this Gitea version; use `actions/tasks` + `actions/jobs/<id>/logs`.
|
||||
|
||||
### OPERATOR ATTESTATIONS (2026-08-31) — capability vs. automated proof
|
||||
|
||||
The operator states, and this log records as fact:
|
||||
|
||||
1. **DR is PROVEN** — Velero backups to the Proxmox server, restore demonstrated. R-STATE-03's
|
||||
capability is delivered and in operational use.
|
||||
2. **Cluster resilience is PROVEN at the dev-cluster level** — node loss / rebuild demonstrated.
|
||||
It is slow because Longhorn storage is on HDD (etcd is the exception, on its dedicated SSD),
|
||||
which is a performance property, not a correctness gap.
|
||||
|
||||
**Distinguish capability from automated proof.** The new repo-derived status report flags
|
||||
`R-STATE-03` as a safety-critical requirement whose L3 test body asserts nothing. That finding
|
||||
stands and is NOT contradicted by the attestation above — it is **test debt, not capability debt**.
|
||||
The system does the thing; the suite does not yet assert that it does. Per the project's own
|
||||
definition of done, only a green requirement-tagged test is acceptance — so the remaining work is
|
||||
to make `dr-restore/chainsaw-test.yaml` and the node-loss test actually assert the behaviour the
|
||||
operator has already demonstrated, and let the runner enforce it. Recording the attestation here
|
||||
does not close the requirement, and the gate must not be softened to make it appear closed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-01 — Phase-7 prep session complete. **PM HANDOFF.**
|
||||
|
||||
Everything below is on branch `docs/phase-7-open-prod-node-1` / **PR #4**, 13 commits, the
|
||||
enforced Gitea gate **green**. Nothing has been installed on prod hardware. `main` is untouched
|
||||
until the operator merges.
|
||||
|
||||
### Delivered
|
||||
1. **Phase 7 opened (prep only)** with the entry-gate shortfall recorded, not waived.
|
||||
2. **T-7.1 checklist** (`infra/PROD-PROVISIONING.md`) written against the real hardware.
|
||||
3. **R-ENV-02 tokenization** — 6 `cluster-vars` knobs; prod would otherwise have come up
|
||||
announcing DEV addresses. Dev proven byte-identical.
|
||||
4. **Prod Ansible**: inventory, vaulted `become` (prod gets NO passwordless sudo), and
|
||||
**target-group parameterisation** so prod reuses the roles unchanged.
|
||||
5. **`node_health` role** — read-only, portable dev+prod, run against both.
|
||||
6. **Repo-derived spec status report** (`run.sh --status`).
|
||||
7. **CI false-green killed** + L0 static tests for R-SEC-01/02 and R-TEST-06.
|
||||
8. **dind readiness fix**; **DNS resilience re-authored** for the current stack.
|
||||
9. **`dr-restore` now asserts** — safety-critical scaffolds 0.
|
||||
|
||||
### Numbers
|
||||
Coverage 37/51 → **40/51**. Executable tests 32 → **36**. Scaffold-only 5 → **4**.
|
||||
Safety-critical without an executable L3 1 → **0**. **PROVEN: 0 — still not derivable**, by
|
||||
design; proof-of-pass is not recoverable from repo state, so a green-run ledger is the only
|
||||
thing that ever closes a requirement.
|
||||
|
||||
### Corrections made during the session (recorded so they are not re-litigated)
|
||||
- **NOPASSWD sudo on prod: REJECTED** by the operator. Prod uses vaulted `become`. My original
|
||||
checklist copied dev's convenience onto prod; that was wrong.
|
||||
- **The "`$i` envsubst bug" was FALSE.** An assessment claimed Flux envsubst ate bare `$i`,
|
||||
making the dind cert-wait bound a no-op. Running the real `flux envsubst` disproved it: bare
|
||||
`$VAR` passes through; only `${VAR}` expands — matching this repo's own contract in
|
||||
`clusters/base/apps-vms.yaml`, itself written after empirical probing. **No escaping was
|
||||
applied**; `$${i}` would have been an active regression (correct only on the envsubst path —
|
||||
on a plain `kustomize build`, `sh` reads `$$` as its PID). Agent reports are not evidence;
|
||||
the tool is.
|
||||
- **The stranded DNS branch was NOT landed.** Its netplan mechanism no longer exists on Debian
|
||||
13; landing it would have broken `node-baseline.yml` on every node.
|
||||
|
||||
### Near-misses worth remembering
|
||||
- A YAML **comment** nearly broke every merge in the repo: Flux envsubst runs over comments too,
|
||||
so a braced name with punctuation is a fatal parse error that fails the whole `apps-wave-1`
|
||||
Kustomization.
|
||||
- `roles/cilium`, `cluster_verify`, `flux_bootstrap` all hardcoded `.cache/dev.kubeconfig` — the
|
||||
file `dev.sh` exports as `$KUBECONFIG`. The moment prod became targetable, a prod run would
|
||||
have silently overwritten it and left the operator's `kubectl` answering from **prod**.
|
||||
- Prod's `GATEWAY_VIP` outside the LB pool would have brought the Gateway up on `.21` while
|
||||
split-horizon DNS answered `.20` — blackholing in-cluster SSO while reporting healthy.
|
||||
|
||||
### BLOCKED ON THE OPERATOR — nothing else moves without these
|
||||
1. **Create `inventory/group_vars/k3s_prod/vault.yml`** (`ansible_become_password`). The
|
||||
passphrase file already exists. This gates SMART, the packages-only baseline, and every
|
||||
future prod play.
|
||||
2. **Drive health decision** — run `smartctl -a /dev/sda` once `smartmontools` lands via
|
||||
`node-baseline.yml -e k3s_hosts=k3s_prod --tags packages`. Prod state on an unmeasured
|
||||
~2011 consumer SSD is the open risk.
|
||||
3. **Prod's second DNS upstream** — `192.168.2.1` is both gateway and only IPv4 resolver.
|
||||
4. **Prod backup target** — `S3_BACKUP_ENDPOINT` is still `TBD-PROD-*`; dev's versitygw is on
|
||||
the far side of the pfSense boundary.
|
||||
5. **`UNIFI_L4_VIP` / `CRAFTY_L4_VIP`** — allocatable from `.20-.30`, not invented here.
|
||||
6. **Merge PR #4.**
|
||||
|
||||
### Known, documented, NOT fixed
|
||||
- `roles/flux_bootstrap` shells to `bootstrap/flux/bootstrap.sh` with a hardcoded
|
||||
`--path=clusters/dev` — retargeting prod needs a `bootstrap/` change.
|
||||
- Prod has no VPN egress group (needs its own vaulted PIA credentials).
|
||||
- **`dr-restore` is AUTHORED, NEVER EXECUTED** (`conformance.home.k3s/never-executed`). It is
|
||||
destructive; converting it to proven needs an operator maintenance-window run. The log must
|
||||
show `DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK`, **not** `DR_RESTORE_SKIPPED_OK` — green but
|
||||
proving nothing.
|
||||
- `dind` → native sidecar `initContainer` with a `docker info` startupProbe (NOT a bare
|
||||
tcpSocket probe) is the right shape on k3s 1.36; deferred so a merge-gate failure is
|
||||
unambiguous.
|
||||
- **Non-ECC RAM — RISK ACCEPTED by the operator (2026-09-01), not an open item.** `node_health`
|
||||
correctly reports ECC status as unknowable (no EDAC controller bound). Operator rationale:
|
||||
this is a 3-node k3s homelab, not enterprise k8s; corruption on one node is outweighed by the
|
||||
other two and results in node ejection. That holds for the dominant failure mode — etcd raft
|
||||
(per-entry CRC + boltdb page checksums, quorum of 2 survives) and Longhorn (3 replicas, hard
|
||||
anti-affinity, rebuild from healthy peers) both detect a *divergent* node and eject/rebuild it.
|
||||
The residual it does not cover is corruption introduced *upstream* of replication: bytes
|
||||
corrupted before the checksum is computed replicate faithfully to all three replicas, so
|
||||
quorum sees no disagreement. Low probability, and the mitigation (ECC hardware) is out of
|
||||
scope for this platform. **Do not re-litigate.** The one thing that operationalises the
|
||||
operator's own argument is Longhorn's `snapshotDataIntegrity`, which is currently left at the
|
||||
chart default rather than set explicitly — see the follow-up in `platform/longhorn/`.
|
||||
- L2/L3 conformance still SKIPs in CI (`docs/plans/l2-l3-conformance-ci-wiring.md`); it needs
|
||||
the trigger-credential decision — a scoped in-cluster SA, never a broad kubeconfig in CI.
|
||||
@@ -89,7 +89,13 @@ spec:
|
||||
# userDataSecretRef only if needed). qemu-guest-agent gives the KubeVirt AgentConnected
|
||||
# signal (proves cloud-init ran); install-uos resolves the latest UOS linux-x64 build from
|
||||
# Ubiquiti's PUBLIC firmware API (no auth) and installs it non-interactively (UI on :11443).
|
||||
# NOTE: apps-vms has NO postBuild.substituteFrom, so shell ${...} below is NOT envsubst'd.
|
||||
# NOTE: apps-vms NOW carries postBuild.substituteFrom cluster-vars (needed for
|
||||
# ${UNIFI_L4_VIP} in 50-service-l4.yaml), so Flux envsubst DOES run over this userData.
|
||||
# Every shell brace-expansion below is therefore escaped as $${...}, which envsubst emits
|
||||
# as a literal ${...} — verified with `flux envsubst`. Bare $VAR and $(...) are left alone
|
||||
# by envsubst and need no escaping. NEVER write an unescaped ${...} here, and never write
|
||||
# ${ARR[0]} even escaped-adjacent: envsubst hard-fails on `[` ("missing closing brace")
|
||||
# and would break the whole apps-vms apply.
|
||||
userData: |-
|
||||
#cloud-config
|
||||
hostname: unifi
|
||||
@@ -103,11 +109,11 @@ spec:
|
||||
content: |
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -z "${UOS_URL:-}" ]; then
|
||||
if [ -z "$${UOS_URL:-}" ]; then
|
||||
UOS_URL="$(curl -fsSL https://fw-update.ui.com/api/firmware-latest | jq -r '._embedded.firmware|map(select(.product=="unifi-os-server" and .platform=="linux-x64"))|sort_by(.version_major,.version_minor,.version_patch)|last._links.data.href')"
|
||||
fi
|
||||
[ -n "${UOS_URL}" ] && [ "${UOS_URL}" != "null" ] || { echo "no UOS URL"; exit 1; }
|
||||
t="$(mktemp)"; echo "downloading ${UOS_URL}"; curl -fL --retry 5 --retry-delay 10 -o "$t" "$UOS_URL"; chmod +x "$t"; echo "installing"; yes | "$t"; echo "done"
|
||||
[ -n "$${UOS_URL}" ] && [ "$${UOS_URL}" != "null" ] || { echo "no UOS URL"; exit 1; }
|
||||
t="$(mktemp)"; echo "downloading $${UOS_URL}"; curl -fL --retry 5 --retry-delay 10 -o "$t" "$UOS_URL"; chmod +x "$t"; echo "installing"; yes | "$t"; echo "done"
|
||||
runcmd:
|
||||
- [ systemctl, enable, --now, qemu-guest-agent ]
|
||||
- [ systemctl, enable, --now, podman.socket ]
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
# CANNOT express: raw TCP on non-HTTP ports (8080 inform, 6789), UDP at all (3478 STUN, 10001
|
||||
# discovery), or the device-facing protocols that never speak HTTP. UniFi device ADOPTION rides
|
||||
# exactly those L4 ports. So the controller is exposed as a type=LoadBalancer Service whose VIP
|
||||
# is pulled from the CiliumLoadBalancerIPPool `default-pool` (192.168.1.53-.60) and ARP-announced
|
||||
# on the flat LAN by the existing CiliumL2AnnouncementPolicy `default-l2` — the SAME L2/LB-IPAM
|
||||
# is pulled from the CiliumLoadBalancerIPPool `default-pool` (dev 192.168.1.53-.60 — the
|
||||
# CILIUM_LB_POOL_START/STOP cluster-vars knobs) and ARP-announced on the flat LAN by the existing
|
||||
# CiliumL2AnnouncementPolicy `default-l2` — the SAME L2/LB-IPAM
|
||||
# machinery the Gateway ingress VIP already uses (platform-config/cilium/*). No new Cilium config
|
||||
# is required; these Services just consume the pool.
|
||||
#
|
||||
@@ -23,8 +24,11 @@
|
||||
# into the guest. externalTrafficPolicy: Local preserves the LAN client source IP and keeps the
|
||||
# VIP pinned to the node running the VM (also what L2 announcement does).
|
||||
#
|
||||
# FLAG: confirm 192.168.1.54 is free, inside default-pool, and reserved in tofu (the Gateway
|
||||
# already holds one pool IP, typically .53). Adjust if it collides.
|
||||
# VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside
|
||||
# default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53).
|
||||
# Substituted by the `apps-vms` Flux Kustomization, which carries postBuild.substituteFrom for
|
||||
# exactly this token; every shell ${...} in 30-virtualmachine.yaml's cloud-init is escaped as $${...}
|
||||
# accordingly. A new environment MUST set UNIFI_L4_VIP to a free address in ITS pool.
|
||||
#
|
||||
# REUSABLE PATTERN (the deferred `minecraft` :25565/L4 problem is the same shape): expose an
|
||||
# L4-only workload as a Cilium LoadBalancer on a pinned, L2-announced VIP with a per-app sharing
|
||||
@@ -39,8 +43,8 @@ metadata:
|
||||
app.kubernetes.io/name: unifi
|
||||
annotations:
|
||||
lbipam.cilium.io/sharing-key: "unifi"
|
||||
lbipam.cilium.io/ips: "192.168.1.54"
|
||||
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> 192.168.1.54.
|
||||
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
|
||||
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP.
|
||||
# This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443.
|
||||
external-dns.alpha.kubernetes.io/hostname: "unifi.home.keithjackson.dev"
|
||||
spec:
|
||||
@@ -65,7 +69,7 @@ metadata:
|
||||
app.kubernetes.io/name: unifi
|
||||
annotations:
|
||||
lbipam.cilium.io/sharing-key: "unifi" # same key + same IP => shares the VIP with -tcp
|
||||
lbipam.cilium.io/ips: "192.168.1.54"
|
||||
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
@@ -74,11 +74,13 @@ spec:
|
||||
# (defaults to the Cluster name), so objects land under s3://cnpg/nextcloud-db/. Path-style HTTP.
|
||||
# Credentials: the scoped key from the in-namespace SOPS Secret nextcloud-db-backup-credentials
|
||||
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
|
||||
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
|
||||
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
|
||||
backup:
|
||||
retentionPolicy: "30d"
|
||||
barmanObjectStore:
|
||||
destinationPath: s3://cnpg
|
||||
endpointURL: http://192.168.1.9:9000
|
||||
endpointURL: ${S3_BACKUP_ENDPOINT}
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: nextcloud-db-backup-credentials
|
||||
|
||||
@@ -75,11 +75,13 @@ spec:
|
||||
# (defaults to the Cluster name), so objects land under s3://cnpg/semaphore-db/. Path-style HTTP.
|
||||
# Credentials: the scoped key from the in-namespace SOPS Secret semaphore-db-backup-credentials
|
||||
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
|
||||
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
|
||||
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
|
||||
backup:
|
||||
retentionPolicy: "30d"
|
||||
barmanObjectStore:
|
||||
destinationPath: s3://cnpg
|
||||
endpointURL: http://192.168.1.9:9000
|
||||
endpointURL: ${S3_BACKUP_ENDPOINT}
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: semaphore-db-backup-credentials
|
||||
|
||||
@@ -103,21 +103,65 @@ spec:
|
||||
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
|
||||
- name: runner
|
||||
image: gitea/act_runner:0.2.11
|
||||
# Wait for the DinD sidecar to publish its TLS client certs into the shared
|
||||
# emptyDir before starting the daemon — act_runner reads /certs/client/ca.pem at
|
||||
# startup and hard-exits if it is missing (a startup race against the sidecar).
|
||||
# Then exec the image's normal entrypoint (/sbin/tini -- /opt/act/run.sh), which
|
||||
# registers-from-env on first boot and runs `act_runner daemon`.
|
||||
# TWO-STAGE BOUNDED WAIT on the DinD sidecar, then exec the image's normal
|
||||
# entrypoint (/sbin/tini -- /opt/act/run.sh), which registers-from-env on first
|
||||
# boot and runs `act_runner daemon`.
|
||||
#
|
||||
# STAGE 1 — the certs must EXIST: act_runner reads /certs/client/ca.pem at startup
|
||||
# and hard-exits if it is missing (a startup race against the sidecar).
|
||||
# STAGE 2 — the daemon must actually ACCEPT a connection: the cert FILE appearing is
|
||||
# necessary but NOT sufficient. dockerd writes the certs and only THEN begins
|
||||
# listening on :2376, so a runner that proceeds on the file alone still hard-exits
|
||||
# with "cannot ping the docker daemon at tcp://localhost:2376" — observed
|
||||
# crashlooping 11 times in ~8 minutes on a fresh pod after a node bounce.
|
||||
# Both loops are bounded (60 x 1s) and exit 1 on timeout, so a genuinely dead sidecar
|
||||
# surfaces as a CrashLoopBackOff with a clear message instead of hanging forever.
|
||||
#
|
||||
# ENVSUBST SAFETY — the BARE $i / $j below are CORRECT. Do not "tidy" them into the
|
||||
# braced form or the double-dollar form; both are wrong here, for different reasons.
|
||||
# clusters/base/apps-wave-1.yaml carries postBuild.substituteFrom cluster-vars, so
|
||||
# Flux envsubst runs over this ENTIRE manifest — YAML comments very much included.
|
||||
# Its contract (drone/envsubst; the same one spelled out in clusters/base/apps-vms.yaml,
|
||||
# and re-verified against this exact file with `flux envsubst`, flux v2.4.0) is:
|
||||
# - A bare $name is NOT a reference to envsubst: it passes through untouched, and
|
||||
# that holds even when the name IS set in cluster-vars. So $i and $j reach the
|
||||
# cluster verbatim and both 60s bounds below are real, enforced bounds.
|
||||
# - $((i + 1)) is likewise untouched: "(" cannot begin a variable name.
|
||||
# - A BRACED dollar-name IS a reference and gets replaced — with the empty string
|
||||
# when the name is unset. One of those here would make a bound read [ "" -ge 60 ],
|
||||
# which errors, short-circuits the &&, and silently turns the wait UNBOUNDED.
|
||||
# - A DOUBLE-dollar braced name is envsubst's escape and survives as the braced
|
||||
# form — still wrong for us, because on any path where envsubst does NOT run
|
||||
# (a plain `kustomize build` or `kubectl apply -k` of this directory) the shell
|
||||
# reads that leading double-dollar as its own PID, so a bound would compare a
|
||||
# string like 1234{i} against 60. The bare form is the only spelling that is
|
||||
# correct on BOTH paths.
|
||||
# - A braced name containing punctuation is a HARD ERROR ("unable to parse variable
|
||||
# name") that fails this whole Kustomization — and this Kustomization gates every
|
||||
# merge in the repo. So never write one anywhere in this file, INCLUDING in a
|
||||
# comment. That is why this note names the three forms in words rather than
|
||||
# showing them: an earlier draft of it spelled them out literally and broke
|
||||
# `flux envsubst` on the file. (Same trap the indexed-expansion warning in
|
||||
# clusters/base/apps-vms.yaml describes.)
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
# Stage 1: the sidecar's TLS client certs must appear in the shared emptyDir.
|
||||
i=0
|
||||
until [ -f /certs/client/ca.pem ]; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -ge 60 ] && { echo "dind certs never appeared" >&2; exit 1; }
|
||||
echo "waiting for dind TLS certs ($i)"; sleep 1
|
||||
done
|
||||
# Stage 2: dockerd must actually accept a connection on :2376. busybox nc in this
|
||||
# Alpine-based image (gitea/act_runner:0.2.11) supports -z (scan) and -w (timeout).
|
||||
j=0
|
||||
until nc -z -w1 127.0.0.1 2376; do
|
||||
j=$((j + 1))
|
||||
[ "$j" -ge 60 ] && { echo "dind daemon never accepted a connection on 2376" >&2; exit 1; }
|
||||
echo "waiting for dind daemon on :2376 ($j)"; sleep 1
|
||||
done
|
||||
exec /sbin/tini -- /opt/act/run.sh
|
||||
env:
|
||||
- name: GITEA_INSTANCE_URL
|
||||
@@ -155,6 +199,31 @@ spec:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
# ---- Docker-in-Docker: the job executor daemon (privileged) ----
|
||||
#
|
||||
# FOLLOW-UP (deliberately NOT bundled into the readiness fix above — own change):
|
||||
# Convert this container into a NATIVE SIDECAR and delete both shell wait-loops.
|
||||
# k3s is now v1.36, where sidecar containers are GA, so kubelet can enforce the
|
||||
# ordering that the loops above hand-roll — which also removes the envsubst hazard
|
||||
# from this file entirely (no shell, no $i/$j, nothing to escape or mis-escape).
|
||||
# Shape to build:
|
||||
# 1. Move this whole container into `initContainers:` as the FIRST entry.
|
||||
# 2. Add `restartPolicy: Always` to it. That single field is what makes an
|
||||
# initContainer a sidecar: it starts before the regular containers, keeps
|
||||
# running alongside them, and is terminated after them.
|
||||
# 3. Give it a `startupProbe` that succeeds only when dockerd accepts a TLS
|
||||
# connection — e.g. `exec` a `docker -H tcp://127.0.0.1:2376 --tlsverify
|
||||
# --tlscacert /certs/client/ca.pem --tlscert /certs/client/cert.pem
|
||||
# --tlskey /certs/client/key.pem info`. Do NOT use a bare `tcpSocket` probe:
|
||||
# it would re-open the same "port accepting but TLS/certs not ready" gap that
|
||||
# Stage 1 above exists to close. `periodSeconds: 1` + `failureThreshold: 60`
|
||||
# preserves today's 60s cap.
|
||||
# 4. kubelet then holds the `runner` container until that startupProbe passes, so
|
||||
# `runner` can drop its `command:` override completely and run the image's
|
||||
# default entrypoint.
|
||||
# Why it is a separate change: this Deployment is the ENFORCED MERGE GATE for this
|
||||
# entire repository. If the conversion is wrong, nothing can merge — so it must land
|
||||
# alone, where the failure is unambiguous and not entangled with anything else, and
|
||||
# only after a real workflow run has been observed green on the converted pod.
|
||||
- name: dind
|
||||
image: docker:27-dind
|
||||
securityContext:
|
||||
|
||||
+5
-1
@@ -44,7 +44,11 @@ touched: ## Print the touched-requirement subset for the current branch vs main
|
||||
@"$(CI_DIR)touched-requirements.sh"
|
||||
|
||||
test: ## Run ci/ script unit tests (no cluster; e.g. touched-requirements content scan)
|
||||
@for t in "$(CI_DIR)"tests/*.test.sh; do echo "== $$t"; bash "$$t"; done
|
||||
@rc=0; for t in "$(CI_DIR)"tests/*.test.sh; do \
|
||||
echo "== $$t"; bash "$$t" || rc=1; \
|
||||
done; \
|
||||
if [ $$rc -ne 0 ]; then echo "ci/tests: FAILED" >&2; else echo "ci/tests: all suites passed"; fi; \
|
||||
exit $$rc
|
||||
|
||||
versions: ## Print pinned tool versions (R-TEST-02)
|
||||
@cat "$(CI_DIR)versions.env"
|
||||
|
||||
+45
-5
@@ -20,17 +20,54 @@ Individual stages:
|
||||
|
||||
```bash
|
||||
make -C ci lint # yaml/lint (no cluster; live now)
|
||||
make -C ci l0 # ServiceDefinition schema + Kyverno (no cluster; live now)
|
||||
make -C ci l0 # schema + Kyverno + L0 static conformance (no cluster; live now)
|
||||
make -C ci l1 # L1 rendering checks (no cluster; live now)
|
||||
make -C ci conformance # L2/L3 in-cluster Chainsaw Job (SKIPS unless CLUSTER_AVAILABLE=true)
|
||||
make -C ci test # unit tests for these scripts themselves (ci/tests/*.test.sh)
|
||||
```
|
||||
|
||||
The `l0` stage has three sub-stages: ServiceDefinition schema validation, the Kyverno policy gate
|
||||
(`kyverno test`), and **`conformance/run.sh --layer l0`** — the whole-repo L0 invariants a
|
||||
per-resource policy cannot express (secret hygiene across every tracked file, least-privilege RBAC,
|
||||
spec-immutability enforcement). See `conformance/tests/README.md`.
|
||||
|
||||
## Exit-code contract
|
||||
|
||||
Every stage script uses: **`0` = pass · `1` = fail (blocks merge) · `3` = SKIPPED** (cluster-gated
|
||||
with no cluster, or an unwired seam / missing tool). A `3` is **never** counted as green — that is how
|
||||
the gate stays honest in Phase 0 (no fake green).
|
||||
|
||||
### The touched-requirement subset can also be *undeterminable* (exit 3)
|
||||
|
||||
`touched-requirements.sh` is the input to the R-TEST-03 decision. It resolves a diff base in this
|
||||
order, **verifying each candidate exists** before using it:
|
||||
|
||||
```
|
||||
BASE_REF (explicit) > GITHUB_BASE_REF (Actions PR runs) > origin/main > main
|
||||
```
|
||||
|
||||
If no candidate resolves, if there is no merge base, if the diff command fails, or if **HEAD is not
|
||||
ahead of the base** (you are sitting on `main`, or the branch is already merged), it prints a loud
|
||||
banner, writes **nothing** to stdout, and exits **3** — plus a machine-readable reason code
|
||||
(`on-base` / `no-base` / `diff-failed`) to `$TOUCHED_REASON_FILE` when that variable names a path.
|
||||
|
||||
**Why this is not cosmetic.** Every one of those conditions used to degrade *silently* to an empty
|
||||
touched set and exit 0. An empty set means `conformance/run.sh` is invoked with no `--requirements`,
|
||||
which means its coverage `--require` gate never runs — so on 2026-08-31 the local gate printed
|
||||
`MERGE GATE: PASS` having checked nothing, while CI (correct base, 17 touched requirements) BLOCKED
|
||||
on real coverage debt. There is no silent narrowing left: the `HEAD~1` last resort and the two-dot
|
||||
diff fallback are both gone. Locked down by `ci/tests/touched-requirements-base.test.sh`.
|
||||
|
||||
`gate.sh` turns exit 3 into an **UNDECIDED** merge decision, never a pass:
|
||||
|
||||
| reason | headline | exit |
|
||||
|---|---|---|
|
||||
| `on-base` | `MERGE GATE: NOT APPLICABLE — no branch under gate` | 0 (a push-to-`main` CI run legitimately lands here; it just must not read as "my branch is green") |
|
||||
| `no-base`, `diff-failed` | `MERGE GATE: BLOCKED` | 1 (the environment cannot support a merge decision; refusing to decide must not look like deciding yes) |
|
||||
|
||||
A stage that needs the subset (`l0.sh`, `l1.sh`) still runs its FULL layer as a superset sanity net
|
||||
in that case — nothing goes untested — and says plainly that the *scoped* check did not happen.
|
||||
|
||||
## Reproducibility (R-TEST-02)
|
||||
|
||||
- **Version-pinned:** all tool versions live in [`versions.env`](versions.env) — the single source of
|
||||
@@ -43,16 +80,19 @@ the gate stays honest in Phase 0 (no fake green).
|
||||
|
||||
```
|
||||
ci/
|
||||
Makefile targets: lint · l0 · l1 · conformance · gate · touched · tools · versions
|
||||
Makefile targets: lint · l0 · l1 · conformance · gate · touched · test · tools · versions
|
||||
versions.env PINNED tool versions (R-TEST-02) — single source of truth
|
||||
lib.sh shared helpers: logging, tool checks, cluster detection, artifact paths
|
||||
yamllint.yaml lint config for the yaml/lint stage
|
||||
lint.sh Stage: yaml/lint
|
||||
l0.sh Stage: ServiceDefinition schema validation + Kyverno L0 policy gate
|
||||
l0.sh Stage: schema validation + Kyverno L0 gate + L0 static conformance
|
||||
l1.sh Stage: L1 rendering checks (Chainsaw L1 via the conformance runner)
|
||||
conformance.sh Stage: L2/L3 in-cluster Chainsaw Job (trigger + collect)
|
||||
touched-requirements.sh compute the R-* subset a change touches (drives the R-TEST-03 filter)
|
||||
touched-requirements.sh compute the R-* subset a change touches (drives the R-TEST-03 filter);
|
||||
exits 3 LOUDLY rather than guessing when the diff base is unreliable
|
||||
gate.sh run all stages + the merge decision (the single command)
|
||||
tests/*.test.sh unit tests for these scripts (`make -C ci test`), run against
|
||||
throwaway git repos via the REPO_ROOT override — no network, no cluster
|
||||
README.md this file
|
||||
```
|
||||
|
||||
@@ -67,7 +107,7 @@ below are this workstream's **assumptions** — flagged for the PM to verify at
|
||||
|---|---|---|---|---|
|
||||
| ServiceDefinition schema validator | WS-GITOPS · **T-0.3** (`schemas/`) | `SCHEMA_VALIDATE_CMD` | `schemas/validate.sh` | `validate.sh catalog/*.yaml` → exit 0/1; `--version` supported |
|
||||
| Kyverno L0 policies + tests | WS-TEST · **T-0.5** (`platform/kyverno/`) | `KYVERNO_POLICY_DIR` | `platform/kyverno` | contains `kyverno-test.yaml` files discoverable by `kyverno test <dir>` |
|
||||
| Conformance runner (Chainsaw) | WS-TEST · **T-0.4** (`conformance/`) | `CONFORMANCE_CMD` | `conformance/run.sh` | `run.sh --layer <l1\|l2,l3> [--requirements R-A,R-B] [--trigger in-cluster-job] --junit <file> --coverage <dir>` |
|
||||
| Conformance runner (Chainsaw) | WS-TEST · **T-0.4** (`conformance/`) | `CONFORMANCE_CMD` | `conformance/run.sh` | `run.sh --layer <l0\|l1\|l2,l3> [--requirements R-A,R-B]`; artifacts land in `conformance/_artifacts/` |
|
||||
| Catalog (ServiceDefinitions) | WS-APPS | `CATALOG_DIR` | `catalog` | `*.yaml` ServiceDefinition files |
|
||||
|
||||
Artifact locations (git-ignored): JUnit → `results/junit/*.xml`; coverage → `reports/coverage/**`.
|
||||
|
||||
+56
-8
@@ -48,6 +48,32 @@ echo >&2
|
||||
log "==== gate decision ===="
|
||||
BLOCK=0
|
||||
|
||||
# --- R-TEST-03 input: the touched-requirement subset --------------------------
|
||||
# Computed ONCE here. ci/touched-requirements.sh exits 3 when it cannot establish a
|
||||
# reliable diff base; that used to degrade to an empty set, which silently disabled the
|
||||
# coverage `--require` gate and let the gate print "MERGE GATE: PASS" having checked
|
||||
# nothing (the 2026-08-31 local false green). An undeterminable subset is now surfaced
|
||||
# as an UNDECIDED merge decision, never as a pass.
|
||||
_reason_file="$(mktemp)"
|
||||
# NOTE: command substitution, NOT `mapfile < <(...)` — process substitution does not
|
||||
# propagate the child's exit status, and the whole point here is to SEE the exit 3.
|
||||
TOUCHED_OUT="$(TOUCHED_REASON_FILE="$_reason_file" "${CI_DIR}/touched-requirements.sh")"
|
||||
TOUCHED_RC=$?
|
||||
TOUCHED_REASON="$(cat "$_reason_file" 2>/dev/null || true)"
|
||||
rm -f "$_reason_file"
|
||||
TOUCHED=()
|
||||
[ -n "$TOUCHED_OUT" ] && mapfile -t TOUCHED <<< "$TOUCHED_OUT"
|
||||
UNDECIDED=0
|
||||
if [ "$TOUCHED_RC" -eq 3 ]; then
|
||||
UNDECIDED=1
|
||||
echo >&2
|
||||
err "R-TEST-03 DECISION INPUT MISSING: the touched-requirement subset could not be"
|
||||
err " determined (${TOUCHED_REASON:-unknown}). The scoped coverage gate did NOT run."
|
||||
summary "- **R-TEST-03 subset UNDETERMINED** (\`${TOUCHED_REASON:-unknown}\`) — the scoped coverage gate did not run."
|
||||
elif [ "${#TOUCHED[@]}" -gt 0 ]; then
|
||||
log "touched requirements: ${TOUCHED[*]}"
|
||||
fi
|
||||
|
||||
# spec-immutability (R-TEST-06): a FAIL always blocks (the spec was modified/drifted).
|
||||
# A skip means the spec repo wasn't available to verify — surfaced, not green.
|
||||
case "${OUTCOME[spec-immutability]}" in
|
||||
@@ -77,9 +103,11 @@ case "${OUTCOME[conformance]}" in
|
||||
skip)
|
||||
if cluster_available; then
|
||||
err "conformance: skipped despite CLUSTER_AVAILABLE=true -> blocks merge"; BLOCK=1
|
||||
elif [ "$UNDECIDED" -eq 1 ]; then
|
||||
warn "conformance L2/L3 UNVERIFIED, and the touched subset is unknown — nothing certified."
|
||||
else
|
||||
mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true)
|
||||
if [ "${#reqs[@]}" -gt 0 ]; then
|
||||
if [ "${#TOUCHED[@]}" -gt 0 ]; then
|
||||
reqs=("${TOUCHED[@]}")
|
||||
warn "conformance L2/L3 UNVERIFIED for touched requirements: ${reqs[*]}"
|
||||
warn " (no cluster — Phase 0). These are NOT certified green."
|
||||
summary "- **UNVERIFIED (no cluster):** ${reqs[*]}"
|
||||
@@ -93,15 +121,35 @@ case "${OUTCOME[conformance]}" in
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
# An UNDECIDED subset is never a pass. Two outcomes, by reason:
|
||||
# on-base -> this checkout has no branch commits to gate (you are on main, or the
|
||||
# branch is already merged; a push-to-main CI run lands here). Legitimate,
|
||||
# so the job is not failed — but the headline says NOT APPLICABLE, never
|
||||
# PASS, so nobody reads it as "my branch is green".
|
||||
# anything -> the environment cannot support a merge decision (no base ref, broken
|
||||
# else diff). Refusing to decide must not look like deciding yes: BLOCK.
|
||||
if [ "$UNDECIDED" -eq 1 ] && [ "$TOUCHED_REASON" != "on-base" ]; then
|
||||
err "cannot compute the R-TEST-03 subset (${TOUCHED_REASON:-unknown}) -> blocks merge"
|
||||
BLOCK=1
|
||||
fi
|
||||
|
||||
echo >&2
|
||||
if [ "$BLOCK" -eq 0 ]; then
|
||||
ok "MERGE GATE: PASS (touched-requirement subset green for the layers that can run)"
|
||||
summary ""
|
||||
summary "**Result: PASS** — L0/L1 green. L2/L3 status per cluster availability above."
|
||||
exit 0
|
||||
else
|
||||
if [ "$BLOCK" -ne 0 ]; then
|
||||
err "MERGE GATE: BLOCKED"
|
||||
summary ""
|
||||
summary "**Result: BLOCKED** — see failures above."
|
||||
exit 1
|
||||
elif [ "$UNDECIDED" -eq 1 ]; then
|
||||
warn "MERGE GATE: NOT APPLICABLE — no branch under gate (${TOUCHED_REASON})."
|
||||
warn " The stages above ran and did not fail, but NO touched-requirement subset was"
|
||||
warn " checked. This is NOT a green branch: check out the branch you mean to gate"
|
||||
warn " (or set BASE_REF) and run it again."
|
||||
summary ""
|
||||
summary "**Result: NOT APPLICABLE** — no branch under gate (\`${TOUCHED_REASON}\`); the R-TEST-03 subset was not checked."
|
||||
exit 0
|
||||
else
|
||||
ok "MERGE GATE: PASS (touched-requirement subset green for the layers that can run)"
|
||||
summary ""
|
||||
summary "**Result: PASS** — L0/L1 green. L2/L3 status per cluster availability above."
|
||||
exit 0
|
||||
fi
|
||||
@@ -7,6 +7,9 @@
|
||||
# Stage 3 L0 : `kyverno test` over platform/kyverno/ — default-deny baseline
|
||||
# present, no plaintext secrets, required labels/egress-class,
|
||||
# ServiceDefinition schema conformance (owner: T-0.5, ADR-0011).
|
||||
# Stage 3b L0 : conformance/run.sh --layer l0 — the WHOLE-REPO L0 invariants that
|
||||
# a per-resource policy cannot express (secret hygiene across every
|
||||
# tracked file, least-privilege RBAC, spec-immutability enforcement).
|
||||
#
|
||||
# Integration seams are env-overridable (see ci/lib.sh + README). When an owning
|
||||
# subtree is still a placeholder, this reports SKIPPED (exit 3) for that sub-stage
|
||||
@@ -73,6 +76,30 @@ else
|
||||
rm -rf "$l0_render"
|
||||
fi
|
||||
|
||||
# ---- Stage 3b: L0 STATIC conformance checks ---------------------------------
|
||||
# docs/14's L0 row is "manifests/ServiceDefinitions obey invariants", which is wider
|
||||
# than what Kyverno alone can express over single resources: whole-repo invariants
|
||||
# (no plaintext secrets anywhere in git, least-privilege RBAC across every tracked
|
||||
# binding, the spec-immutability gate's own behaviour) live in the conformance
|
||||
# harness's tests/l0-static subtree and run with no cluster. This stage executes them
|
||||
# through the same single-command runner CI and a laptop both use (R-TEST-02).
|
||||
log "Stage: L0 static conformance checks (${CONFORMANCE_CMD} --layer l0)"
|
||||
if ! entrypoint_ready "$CONFORMANCE_CMD"; then
|
||||
skip "conformance runner not present at '${CONFORMANCE_CMD}' — UNWIRED, not green"
|
||||
summary "- l0-static: **SKIPPED** (conformance runner missing)"
|
||||
if [ "$rc" -eq 0 ]; then rc=3; fi
|
||||
else
|
||||
load_touched_requirements
|
||||
if "$CONFORMANCE_CMD" --layer l0 ${REQ_ARGS[@]+"${REQ_ARGS[@]}"}; then
|
||||
ok "L0 static conformance checks passed"
|
||||
summary "- l0-static: **passed**"
|
||||
else
|
||||
err "L0 static conformance checks failed"
|
||||
summary "- l0-static: **FAILED**"
|
||||
rc=1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$rc" in
|
||||
0) ok "L0 stage complete" ;;
|
||||
3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;;
|
||||
|
||||
@@ -20,19 +20,16 @@ if ! entrypoint_ready "$CONFORMANCE_CMD"; then
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Touched-requirement subset (R-TEST-03). Empty subset => run all L1 as a sanity net.
|
||||
mapfile -t reqs < <("${CI_DIR}/touched-requirements.sh" || true)
|
||||
req_args=()
|
||||
if [ "${#reqs[@]}" -gt 0 ]; then
|
||||
log "touched requirements: ${reqs[*]}"
|
||||
req_args=(--requirements "$(IFS=,; echo "${reqs[*]}")")
|
||||
fi
|
||||
# Touched-requirement subset (R-TEST-03). An empty OR undeterminable subset => run all
|
||||
# L1 as a superset sanity net, and SAY SO — see load_touched_requirements in ci/lib.sh.
|
||||
load_touched_requirements
|
||||
req_args=(${REQ_ARGS[@]+"${REQ_ARGS[@]}"})
|
||||
|
||||
# RECONCILED (PM 2026-08-16): conformance/run.sh's real interface is `--layer <l1|l2|l3>`
|
||||
# [--requirements R-A,R-B]; it ALWAYS writes artifacts to a FIXED location
|
||||
# (conformance/_artifacts/) and does NOT accept --junit/--coverage. Invoke by layer +
|
||||
# touched subset, then COLLECT its artifacts into the CI artifact dirs.
|
||||
"$CONFORMANCE_CMD" --layer l1 "${req_args[@]}"
|
||||
"$CONFORMANCE_CMD" --layer l1 ${req_args[@]+"${req_args[@]}"}
|
||||
|
||||
CONF_ART="$(dirname "$CONFORMANCE_CMD")/_artifacts"
|
||||
if [ -f "${CONF_ART}/report.junit.xml" ]; then
|
||||
|
||||
@@ -85,3 +85,38 @@ cluster_available() {
|
||||
}
|
||||
|
||||
mkartifacts() { mkdir -p "$JUNIT_DIR" "$COVERAGE_DIR"; }
|
||||
|
||||
# --- Touched-requirement subset (R-TEST-03 input) ----------------------------
|
||||
# Sets: TOUCHED_RC (0 ok / 3 not determinable), TOUCHED_REQS[] and REQ_ARGS[]
|
||||
# (ready to splice into a conformance/run.sh invocation).
|
||||
#
|
||||
# ci/touched-requirements.sh exits 3 — LOUDLY — when it cannot establish a reliable
|
||||
# diff base, instead of the old silent degradation to an empty set. An empty set
|
||||
# means run.sh gets no `--requirements`, which means its coverage `--require` gate
|
||||
# never runs; treating "could not determine" as "nothing touched" is precisely the
|
||||
# false green this helper exists to prevent. A stage still runs its FULL layer in
|
||||
# that case (a superset, so nothing goes untested), but says plainly that the
|
||||
# SCOPED check did not happen; ci/gate.sh turns that into an UNDECIDED decision.
|
||||
load_touched_requirements() {
|
||||
local out rc=0
|
||||
out="$("${CI_DIR}/touched-requirements.sh")" || rc=$?
|
||||
TOUCHED_RC=$rc
|
||||
TOUCHED_REQS=()
|
||||
# `if`, not `[ ... ] && ...`: under `set -e` a false AND-list is the caller's last
|
||||
# command status and would abort the stage script on an EMPTY touched set.
|
||||
if [ -n "$out" ]; then mapfile -t TOUCHED_REQS <<< "$out"; fi
|
||||
REQ_ARGS=()
|
||||
if [ "$rc" -eq 3 ]; then
|
||||
warn "touched-requirement subset UNDETERMINED (reason logged above)."
|
||||
warn " Running the FULL layer as a superset sanity net; the SCOPED R-TEST-03"
|
||||
warn " coverage check is NOT performed here — ci/gate.sh reports that as UNDECIDED."
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
warn "touched-requirements.sh exited ${rc}; proceeding with the full layer."
|
||||
elif [ "${#TOUCHED_REQS[@]}" -gt 0 ]; then
|
||||
log "touched requirements: ${TOUCHED_REQS[*]}"
|
||||
REQ_ARGS=(--requirements "$(IFS=,; echo "${TOUCHED_REQS[*]}")")
|
||||
else
|
||||
log "empty touched-requirement set (base WAS established) — running the full layer."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/tests/touched-requirements-base.test.sh — regression tests for BASE-REF RESOLUTION
|
||||
# in ci/touched-requirements.sh.
|
||||
#
|
||||
# THE BUG THESE LOCK DOWN (2026-08-31, local/CI divergence):
|
||||
# `make -C ci gate` on a feature branch printed
|
||||
# "no changed files vs base; empty touched-requirement set"
|
||||
# and then "MERGE GATE: PASS" — while CI, with a correct base, computed 17 touched
|
||||
# requirements and BLOCKED on real coverage debt. Root cause: EVERY way of failing to
|
||||
# establish a diff base degraded silently to an EMPTY touched set and exit 0. An empty
|
||||
# set means conformance/run.sh is invoked without `--requirements`, which means its
|
||||
# coverage `--require` gate never runs — so the gate reported PASS having checked
|
||||
# nothing. A false green is worse than a red.
|
||||
#
|
||||
# The fix: an undeterminable base is a LOUD exit 3 (ci/README.md's SKIPPED code) with
|
||||
# EMPTY stdout and a machine-readable reason code, never exit 0. These tests assert
|
||||
# that for each way the base can be undeterminable, and assert the good paths still work.
|
||||
#
|
||||
# The tests point the real script at THROWAWAY git repos via the REPO_ROOT override
|
||||
# (ci/lib.sh honours a pre-set REPO_ROOT); the script runs from its true location so it
|
||||
# still sources the real lib.sh. No network, no cluster, and the working repo is never
|
||||
# touched.
|
||||
#
|
||||
# Run: ci/tests/touched-requirements-base.test.sh (or `make -C ci test`)
|
||||
set -uo pipefail # NOT -e: these tests deliberately run a script that exits non-zero.
|
||||
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="${CI_DIR}/touched-requirements.sh"
|
||||
|
||||
PASS=0 FAIL=0
|
||||
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' FAIL %s\n' "$*" >&2; FAIL=$((FAIL+1)); }
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# newrepo <dir> — a throwaway repo with one baseline commit on `main`.
|
||||
newrepo() {
|
||||
local d="$1"
|
||||
mkdir -p "$d/notes"
|
||||
git -C "$d" init -q -b main
|
||||
git -C "$d" config user.email [email protected]
|
||||
git -C "$d" config user.name t
|
||||
git -C "$d" config commit.gpgsign false # throwaway: never sign (ignore host gpg config)
|
||||
echo baseline > "$d/notes/base.txt"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm baseline
|
||||
}
|
||||
|
||||
# commit_touching <dir> <text> — a commit whose content declares an R-ID.
|
||||
commit_touching() {
|
||||
local d="$1" text="$2"
|
||||
printf 'name: %s\n' "$text" > "$d/notes/decl.yaml"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm change
|
||||
}
|
||||
|
||||
# Runs the script in <dir>; sets RC, OUT (stdout), ERRTXT (stderr), REASON.
|
||||
run_in() {
|
||||
local d="$1"; shift
|
||||
local reason_file="${TMP}/reason.$$"
|
||||
: > "$reason_file"
|
||||
OUT="$(REPO_ROOT="$d" TOUCHED_REASON_FILE="$reason_file" "$@" "$SCRIPT" 2>"${TMP}/err.$$")"
|
||||
RC=$?
|
||||
ERRTXT="$(cat "${TMP}/err.$$")"
|
||||
REASON="$(cat "$reason_file")"
|
||||
}
|
||||
|
||||
# assert_refusal <label> <expected-reason>
|
||||
assert_refusal() {
|
||||
local label="$1" want="$2"
|
||||
[ "$RC" = 3 ] && ok "${label}: exits 3 (SKIPPED), not 0" || bad "${label}: rc=${RC}, expected 3"
|
||||
[ -z "$OUT" ] && ok "${label}: stdout is EMPTY (no silent requirement list)" \
|
||||
|| bad "${label}: stdout was '${OUT}'"
|
||||
[ "$REASON" = "$want" ] && ok "${label}: reason code '${want}'" \
|
||||
|| bad "${label}: reason '${REASON}', expected '${want}'"
|
||||
grep -q "NOT DETERMINABLE" <<<"$ERRTXT" && ok "${label}: announces the refusal LOUDLY on stderr" \
|
||||
|| bad "${label}: stderr lacked the loud banner"
|
||||
}
|
||||
|
||||
echo "== case 1: a branch ahead of local main (no remote) resolves and reports"
|
||||
R1="${TMP}/r1"; newrepo "$R1"
|
||||
git -C "$R1" checkout -q -b feature
|
||||
commit_touching "$R1" implements-R-ALPHA-01
|
||||
run_in "$R1"
|
||||
[ "$RC" = 0 ] && ok "ahead-of-main: exits 0" || bad "ahead-of-main: rc=${RC}"
|
||||
grep -qx "R-ALPHA-01" <<<"$OUT" && ok "ahead-of-main: emits the touched R-ID" \
|
||||
|| bad "ahead-of-main: output was '${OUT}'"
|
||||
grep -q "diff base: main" <<<"$ERRTXT" && ok "ahead-of-main: names the base it used" \
|
||||
|| bad "ahead-of-main: base not reported: ${ERRTXT}"
|
||||
|
||||
echo "== case 2: THE FALSE GREEN — HEAD is the base branch itself"
|
||||
# This is the state that produced 'no changed files vs base' + 'MERGE GATE: PASS'
|
||||
# locally while CI blocked. It must now refuse, not report an empty set.
|
||||
R2="${TMP}/r2"; newrepo "$R2"
|
||||
git -C "$R2" checkout -q -b feature
|
||||
commit_touching "$R2" implements-R-ALPHA-01
|
||||
git -C "$R2" checkout -q main # <- sitting on the base branch
|
||||
run_in "$R2"
|
||||
assert_refusal "on-base" "on-base"
|
||||
|
||||
echo "== case 3: a branch already merged into the base (HEAD is an ancestor)"
|
||||
R3="${TMP}/r3"; newrepo "$R3"
|
||||
git -C "$R3" checkout -q -b feature
|
||||
commit_touching "$R3" implements-R-ALPHA-01
|
||||
git -C "$R3" checkout -q main
|
||||
git -C "$R3" merge -q --no-edit feature
|
||||
git -C "$R3" checkout -q feature # feature is now behind main
|
||||
run_in "$R3"
|
||||
assert_refusal "already-merged" "on-base"
|
||||
|
||||
echo "== case 4: no base ref resolves at all (no main, no origin/main)"
|
||||
R4="${TMP}/r4"; newrepo "$R4"
|
||||
git -C "$R4" checkout -q -b work
|
||||
commit_touching "$R4" implements-R-ALPHA-01
|
||||
git -C "$R4" branch -q -D main
|
||||
run_in "$R4"
|
||||
assert_refusal "no-base-ref" "no-base"
|
||||
|
||||
echo "== case 5: a single-commit repo with no main (the removed HEAD~1 last resort)"
|
||||
# HEAD~1 does not exist here. The old code fell back to it, the diff failed, and the
|
||||
# empty result was reported as 'no changed files' + exit 0 — a silent false green.
|
||||
R5="${TMP}/r5"; mkdir -p "$R5/notes"
|
||||
git -C "$R5" init -q -b work
|
||||
git -C "$R5" config user.email [email protected]; git -C "$R5" config user.name t
|
||||
git -C "$R5" config commit.gpgsign false
|
||||
echo only > "$R5/notes/only.txt"; git -C "$R5" add -A; git -C "$R5" commit -qm only
|
||||
run_in "$R5"
|
||||
assert_refusal "single-commit-no-main" "no-base"
|
||||
|
||||
echo "== case 6: BASE_REF names a ref that does not exist"
|
||||
R6="${TMP}/r6"; newrepo "$R6"
|
||||
git -C "$R6" checkout -q -b feature
|
||||
commit_touching "$R6" implements-R-ALPHA-01
|
||||
run_in "$R6" env BASE_REF=refs/heads/nope
|
||||
assert_refusal "bogus-BASE_REF" "no-base"
|
||||
|
||||
echo "== case 7: GITHUB_BASE_REF set but absent from the checkout (shallow clone)"
|
||||
R7="${TMP}/r7"; newrepo "$R7"
|
||||
git -C "$R7" checkout -q -b feature
|
||||
commit_touching "$R7" implements-R-ALPHA-01
|
||||
run_in "$R7" env GITHUB_BASE_REF=release-9
|
||||
assert_refusal "missing-GITHUB_BASE_REF" "no-base"
|
||||
|
||||
echo "== case 8: origin/main is preferred over a stale local main, and staleness warns"
|
||||
R8="${TMP}/r8"; newrepo "$R8"
|
||||
UP="${TMP}/r8-origin.git"; git init -q --bare -b main "$UP"
|
||||
git -C "$R8" remote add origin "$UP"
|
||||
git -C "$R8" push -q origin main
|
||||
# advance origin/main beyond the local main ref, then branch off it
|
||||
git -C "$R8" checkout -q -b ahead
|
||||
echo more > "$R8/notes/more.txt"; git -C "$R8" add -A; git -C "$R8" commit -qm more
|
||||
git -C "$R8" push -q origin ahead:main
|
||||
git -C "$R8" fetch -q origin
|
||||
git -C "$R8" checkout -q -b feature origin/main
|
||||
commit_touching "$R8" implements-R-ALPHA-01
|
||||
run_in "$R8"
|
||||
[ "$RC" = 0 ] && ok "origin-preferred: exits 0" || bad "origin-preferred: rc=${RC}"
|
||||
grep -q "diff base: origin/main" <<<"$ERRTXT" && ok "origin-preferred: uses origin/main, not local main" \
|
||||
|| bad "origin-preferred: base line was: ${ERRTXT}"
|
||||
grep -q "may be stale" <<<"$ERRTXT" && ok "origin-preferred: warns that local main differs" \
|
||||
|| bad "origin-preferred: no staleness warning"
|
||||
grep -qx "R-ALPHA-01" <<<"$OUT" && ok "origin-preferred: emits the touched R-ID" \
|
||||
|| bad "origin-preferred: output was '${OUT}'"
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "touched-requirements base-resolution tests: PASS (${PASS} assertions)"; exit 0
|
||||
fi
|
||||
echo "touched-requirements base-resolution tests: FAIL (${FAIL} failed, ${PASS} passed)" >&2
|
||||
exit 1
|
||||
+115
-18
@@ -28,22 +28,93 @@ CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "${CI_DIR}/lib.sh"
|
||||
|
||||
# Base ref to diff against. In Gitea/GitHub Actions PR runs this is the target
|
||||
# branch (main). Locally, default to main; override with BASE_REF.
|
||||
# ---------------------------------------------------------------------------------
|
||||
# BASE-REF RESOLUTION — and why it now REFUSES rather than guesses.
|
||||
#
|
||||
# BUG THIS FIXES (local/CI divergence, 2026-08-31): every failure to establish a
|
||||
# reliable diff base used to degrade SILENTLY to an EMPTY touched set, which main()
|
||||
# reported as "no changed files vs base" and exit 0. An empty set means the coverage
|
||||
# `--require` gate is never applied, so the merge gate printed "MERGE GATE: PASS"
|
||||
# having checked NOTHING — while CI, with a correct base, computed 17 touched
|
||||
# requirements and blocked. A false green is the worst outcome this project can
|
||||
# produce, so every one of those degradations is now a LOUD exit 3:
|
||||
#
|
||||
# * HEAD~1 last resort REMOVED. It silently narrowed the diff to the last commit
|
||||
# (and on a one-commit repo failed into an empty set).
|
||||
# * two-dot fallback REMOVED. It masked a failing three-dot diff as "no changes".
|
||||
# * unresolvable base exit 3 (was: HEAD~1 or empty).
|
||||
# * diff command fails exit 3 (was: empty set).
|
||||
# * HEAD not ahead of exit 3 (was: exit 0 with an empty set — this is the case
|
||||
# the base that produced the false green: running the gate while
|
||||
# sitting on `main`, or on a branch already merged).
|
||||
#
|
||||
# Exit 3 is the ci/README.md "SKIPPED / not-run" code: NOT a pass, NOT a fail. Callers
|
||||
# (ci/gate.sh) must surface it as an UNDECIDED merge decision. An honest, noisy skip
|
||||
# beats a quiet false pass.
|
||||
#
|
||||
# Order of preference: BASE_REF (explicit) > GITHUB_BASE_REF (Actions PR runs, target
|
||||
# branch) > origin/main > main. Each candidate is VERIFIED to resolve to a commit
|
||||
# before it is used, so a shallow checkout that lacks origin/main is reported, not
|
||||
# papered over.
|
||||
: "${BASE_REF:=}"
|
||||
resolve_base() {
|
||||
if [ -n "$BASE_REF" ]; then echo "$BASE_REF"; return; fi
|
||||
if [ -n "${GITHUB_BASE_REF:-}" ]; then echo "origin/${GITHUB_BASE_REF}"; return; fi
|
||||
if git -C "$REPO_ROOT" rev-parse --verify -q origin/main >/dev/null; then echo "origin/main"; return; fi
|
||||
if git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then echo "main"; return; fi
|
||||
echo "HEAD~1" # last resort
|
||||
BASE="" # resolved base ref
|
||||
BASE_SOURCE="" # where it came from (for the log line)
|
||||
BASE_PROBLEM="" # why resolution failed, when it did
|
||||
|
||||
rev() { git -C "$REPO_ROOT" rev-parse --verify -q "$1^{commit}" 2>/dev/null; }
|
||||
|
||||
# Loud, structured refusal. Prints to stderr only; stdout stays empty so a caller that
|
||||
# captures stdout gets NO requirement list rather than a silently-empty one.
|
||||
#
|
||||
# $1 is a machine-readable REASON CODE, also written to $TOUCHED_REASON_FILE when that
|
||||
# env var names a path. ci/gate.sh uses it to tell the two cases apart:
|
||||
# on-base HEAD is the base branch (or already merged) — nothing to gate. Legitimate
|
||||
# (a push-to-main CI run lands here), so the gate reports NOT APPLICABLE
|
||||
# rather than PASS, and does not fail the job.
|
||||
# no-base no reliable base ref exists in this checkout — the ENVIRONMENT is broken,
|
||||
# diff-failed or git could not produce the diff. A merge decision is impossible, so the
|
||||
# gate BLOCKS rather than guessing.
|
||||
undecidable() {
|
||||
local code="$1"; shift
|
||||
if [ -n "${TOUCHED_REASON_FILE:-}" ]; then printf '%s\n' "$code" > "$TOUCHED_REASON_FILE"; fi
|
||||
err "TOUCHED-REQUIREMENT SET NOT DETERMINABLE (${code})"
|
||||
err " reason: $*"
|
||||
err " consequence: the R-TEST-03 coverage gate CANNOT be applied to this checkout."
|
||||
err " This is exit 3 (SKIPPED / not-run), never a pass — see ci/README.md."
|
||||
err " Fix one of:"
|
||||
err " * check out the feature branch you mean to gate (not the base branch), or"
|
||||
err " * git fetch origin main (so origin/main exists and is current), or"
|
||||
err " * BASE_REF=<ref> make -C ci gate to name the base explicitly."
|
||||
exit 3
|
||||
}
|
||||
|
||||
resolve_base() {
|
||||
local cand
|
||||
if [ -n "$BASE_REF" ]; then
|
||||
if rev "$BASE_REF" >/dev/null; then BASE="$BASE_REF"; BASE_SOURCE="BASE_REF"; return 0; fi
|
||||
BASE_PROBLEM="BASE_REF='${BASE_REF}' does not resolve to a commit in ${REPO_ROOT}"
|
||||
return 1
|
||||
fi
|
||||
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
||||
for cand in "origin/${GITHUB_BASE_REF}" "${GITHUB_BASE_REF}"; do
|
||||
if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="GITHUB_BASE_REF"; return 0; fi
|
||||
done
|
||||
BASE_PROBLEM="GITHUB_BASE_REF='${GITHUB_BASE_REF}' is set, but neither 'origin/${GITHUB_BASE_REF}' nor '${GITHUB_BASE_REF}' exists here (shallow checkout? the workflow needs fetch-depth: 0)"
|
||||
return 1
|
||||
fi
|
||||
for cand in origin/main main; do
|
||||
if rev "$cand" >/dev/null; then BASE="$cand"; BASE_SOURCE="default"; return 0; fi
|
||||
done
|
||||
BASE_PROBLEM="no base ref resolves (tried origin/main, main) in ${REPO_ROOT}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Both diff helpers assume BASE is already resolved and validated by main(). They return
|
||||
# git's exit status so the CALLER can distinguish "no changes" from "the diff failed" —
|
||||
# conflating those two is exactly what produced the false green.
|
||||
changed_files() {
|
||||
local base; base="$(resolve_base)"
|
||||
# Three-dot: changes on this branch since it diverged from base.
|
||||
if git -C "$REPO_ROOT" diff --name-only "${base}...HEAD" 2>/dev/null; then return; fi
|
||||
git -C "$REPO_ROOT" diff --name-only "${base}" 2>/dev/null || true
|
||||
git -C "$REPO_ROOT" diff --name-only "${BASE}...HEAD"
|
||||
}
|
||||
|
||||
# The ADDED/REMOVED lines of the change, for the R-ID content scan — excluding
|
||||
@@ -92,10 +163,8 @@ changed_files() {
|
||||
# carry AUTHORITATIVE `conformance.home.k3s/R-*` labels and are deliberately still scanned.
|
||||
# Three-dot to match changed_files(): changes on this branch since it diverged from base.
|
||||
changed_diff() {
|
||||
local base; base="$(resolve_base)"
|
||||
local -a excl=(':(exclude)*.md' ':(exclude)ci/tests/')
|
||||
if git -C "$REPO_ROOT" diff -U0 "${base}...HEAD" -- . "${excl[@]}" 2>/dev/null; then return; fi
|
||||
git -C "$REPO_ROOT" diff -U0 "${base}" -- . "${excl[@]}" 2>/dev/null || true
|
||||
git -C "$REPO_ROOT" diff -U0 "${BASE}...HEAD" -- . "${excl[@]}"
|
||||
}
|
||||
|
||||
# Coarse path -> requirement map. Deliberately conservative; extend as subtrees
|
||||
@@ -120,9 +189,34 @@ path_requirements() {
|
||||
}
|
||||
|
||||
main() {
|
||||
local files reqs=""
|
||||
files="$(changed_files)"
|
||||
[ -n "$files" ] || { log "no changed files vs base; empty touched-requirement set"; exit 0; }
|
||||
local files reqs="" rc=0 base_sha head_sha local_main merge_base
|
||||
|
||||
resolve_base || undecidable no-base "$BASE_PROBLEM"
|
||||
base_sha="$(rev "$BASE")" || undecidable no-base "resolved base '${BASE}' vanished"
|
||||
head_sha="$(rev HEAD)" || undecidable no-base "HEAD does not resolve to a commit (empty repo?)"
|
||||
log "diff base: ${BASE} (${BASE_SOURCE}) @ ${base_sha:0:12} ... HEAD @ ${head_sha:0:12}"
|
||||
|
||||
# Advisory: a never-fetched origin/main silently WIDENS the set (over-broad, never a
|
||||
# false green) — surfaced so an unexpectedly large touched set has an explanation.
|
||||
if [ "$BASE" = "origin/main" ] && local_main="$(rev main)" && [ -n "$local_main" ] \
|
||||
&& [ "$local_main" != "$base_sha" ]; then
|
||||
warn "local 'main' (${local_main:0:12}) != origin/main (${base_sha:0:12}) — if origin/main"
|
||||
warn " was not fetched recently the touched set may be stale: git fetch origin main"
|
||||
fi
|
||||
|
||||
merge_base="$(git -C "$REPO_ROOT" merge-base "$BASE" HEAD 2>/dev/null)" \
|
||||
|| undecidable no-base "no merge base between '${BASE}' and HEAD (unrelated histories, or a shallow clone)"
|
||||
if [ "$merge_base" = "$head_sha" ]; then
|
||||
undecidable on-base "HEAD is not ahead of '${BASE}' (merge-base == HEAD): this checkout contains no branch commits to gate. The empty touched set would be empty BY DEFAULT, not by verification."
|
||||
fi
|
||||
|
||||
files="$(changed_files)" || rc=$?
|
||||
[ "$rc" -eq 0 ] || undecidable diff-failed "git diff --name-only ${BASE}...HEAD failed (rc=${rc})"
|
||||
if [ -z "$files" ]; then
|
||||
warn "HEAD is ahead of ${BASE} but the three-dot diff is empty (self-cancelling commits?)"
|
||||
warn " -> genuinely empty touched-requirement set; base WAS established."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
@@ -135,7 +229,10 @@ main() {
|
||||
# what remains is the SOURCE line, and `grep -vE '^[[:space:]]*#'` drops whole-line
|
||||
# YAML/shell comments (rule 3 in changed_diff's header) BEFORE the R-ID grep — a
|
||||
# requirement cited only in a `#` comment is prose, not a declaration.
|
||||
reqs+=" $(changed_diff \
|
||||
local diff_txt
|
||||
diff_txt="$(changed_diff)" || rc=$?
|
||||
[ "$rc" -eq 0 ] || undecidable diff-failed "git diff -U0 ${BASE}...HEAD failed (rc=${rc})"
|
||||
reqs+=" $(printf '%s\n' "$diff_txt" \
|
||||
| grep -E '^[+-]' \
|
||||
| grep -vE '^(\+\+\+|---)' \
|
||||
| sed -E 's/^[+-]//' \
|
||||
|
||||
@@ -40,6 +40,31 @@ they add no resources of their own and never copy/fork a base manifest. Promotio
|
||||
([`docs/02`](../../home-network-k3s/docs/02-environments-and-provisioning.md)) is a change to the
|
||||
prod overlay's variables, nothing else.
|
||||
|
||||
### LAN addressing is a knob, not a literal (fixed 2026-08-31)
|
||||
|
||||
Until 2026-08-31 the base manifests hardcoded the **dev** LAN: the k3s API host Cilium dials, the
|
||||
Cilium LB IP pool range, the pinned crafty/unifi L4 VIPs and the off-cluster S3 backup endpoint. That
|
||||
broke R-ENV-02 outright — `clusters/prod` would have rendered dev addresses (192.168.1.x) onto the
|
||||
prod LAN (192.168.2.x). They are now the `cluster-vars` "LAN addressing" block
|
||||
(`K3S_API_HOST`, `CILIUM_LB_POOL_START`/`_STOP`, `UNIFI_L4_VIP`, `CRAFTY_L4_VIP`,
|
||||
`S3_BACKUP_ENDPOINT`, alongside the pre-existing `GATEWAY_VIP`), set explicitly by BOTH overlays.
|
||||
|
||||
Two rules when touching that block:
|
||||
|
||||
1. **Substitution coverage.** A `${TOKEN}` only resolves in a path whose Flux Kustomization declares
|
||||
`postBuild.substituteFrom: cluster-vars`; anywhere else the literal string `${TOKEN}` ships to the
|
||||
cluster. `apps-vms` was the one path without it and now has it — which means every shell `${...}`
|
||||
in a VM manifest's cloud-init must be escaped `$${...}` (see `clusters/base/apps-vms.yaml` for the
|
||||
full envsubst escape contract).
|
||||
2. **`GATEWAY_VIP` must be inside `CILIUM_LB_POOL_START..STOP`,** and in practice equal to START: the
|
||||
`shared` Gateway pins no address, so Cilium LB-IPAM gives it the pool's first free IP while
|
||||
split-horizon CoreDNS answers with `${GATEWAY_VIP}`. A VIP outside the pool blackholes in-cluster
|
||||
hairpin traffic while the Gateway still looks healthy.
|
||||
|
||||
Values the operator has not decided are `TBD-PROD-*` placeholders, not guesses: prod inherits the
|
||||
DEV base defaults if an overlay op is simply omitted, so an invalid value that fails loudly is
|
||||
strictly safer than a plausible one that quietly comes up on the wrong network.
|
||||
|
||||
## How Flux is pointed at an environment
|
||||
|
||||
`flux bootstrap` (run by WS-INFRA in Phase 1) targets one environment's path per cluster:
|
||||
|
||||
@@ -23,7 +23,21 @@ spec:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./apps/vms
|
||||
# NO postBuild.substituteFrom: Flux's envsubst would run over the ENTIRE manifest, including the
|
||||
# VM cloud-init shell scripts, and mangle every `${...}` (e.g. ${UOS_URL}, ${PIPESTATUS[0]}) —
|
||||
# corrupting the in-guest install. VM manifests reference no cluster-vars, so substitution is
|
||||
# unneeded here. A future VM that needs a cluster-var must escape its shell `${...}` as `$${...}`.
|
||||
# postBuild.substituteFrom ENABLED 2026-08-31 (R-ENV-02). It was previously omitted because Flux's
|
||||
# envsubst runs over the ENTIRE manifest — including VM cloud-init shell scripts — and would mangle
|
||||
# every `${...}`; the omission was safe only while no VM manifest referenced a cluster-var. It now
|
||||
# does: apps/vms/unifi/50-service-l4.yaml pins the UniFi L4 VIP from ${UNIFI_L4_VIP} instead of the
|
||||
# hardcoded dev-LAN literal that would have shipped 192.168.1.54 onto the PROD LAN.
|
||||
#
|
||||
# The escape contract the old comment prescribed is now in force and MUST be honoured by every VM
|
||||
# manifest under this path (verified with `flux envsubst`):
|
||||
# `${VAR}` -> substituted (empty when unset) => escape shell braces as `$${VAR}`, which
|
||||
# envsubst emits back as a literal `${VAR}`.
|
||||
# `$VAR` -> left alone by envsubst => bare shell vars need NO escaping.
|
||||
# `$(cmd)` -> left alone by envsubst => command substitution needs NO escaping.
|
||||
# `${ARR[0]}` (e.g. ${PIPESTATUS[0]}) -> HARD ERROR "missing closing brace", which fails this
|
||||
# whole Kustomization. NEVER use an indexed expansion here, escaped or not.
|
||||
postBuild:
|
||||
substituteFrom:
|
||||
- kind: ConfigMap
|
||||
name: cluster-vars
|
||||
@@ -21,13 +21,56 @@ data:
|
||||
DNS_ZONE: "keithjackson.dev" # the actual Cloudflare ZONE (BASE_DOMAIN is a subdomain of it);
|
||||
# ExternalDNS domain-filter must be the zone, not the subdomain
|
||||
ACME_CLUSTER_ISSUER: "letsencrypt-staging" # dev: staging | prod: letsencrypt-prod (ADR-0006)
|
||||
GATEWAY_VIP: "10.73.0.240" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
|
||||
# ingress VIP of the `shared` Gateway (first IP of the Cilium
|
||||
# LB IP pool 10.73.0.240-.250, platform-config/cilium/l2-ip-pool.yaml).
|
||||
# coredns-custom answers *.home.keithjackson.dev with THIS so pod->
|
||||
# public-hostname traffic hairpins through the Gateway (correct SNI ->
|
||||
# wildcard cert). Base default = dev VIP; a prod overlay must set the
|
||||
# prod LAN VIP alongside the prod L2 pool range when hardware is built.
|
||||
GATEWAY_VIP: "192.168.1.53" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
|
||||
# ingress VIP of the `shared` Gateway. MUST equal the FIRST address of
|
||||
# ${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP} below — the Gateway
|
||||
# takes the pool's first free IP, and coredns-custom answers
|
||||
# *.home.keithjackson.dev with THIS so pod -> public-hostname traffic
|
||||
# hairpins through the Gateway (correct SNI -> wildcard cert).
|
||||
# Base default = the DEV LAN VIP (was 10.73.0.240, an address on the
|
||||
# long-RETIRED libvirt network — corrected 2026-08-31 so an overlay that
|
||||
# forgets this knob at least lands on a REAL network). A prod overlay
|
||||
# MUST still set the prod LAN VIP alongside the prod L2 pool range.
|
||||
|
||||
# --- LAN addressing (R-ENV-02) — the ONE block every new environment must re-supply -------------
|
||||
# These were HARDCODED into base manifests until 2026-08-31, which broke the golden constraint:
|
||||
# `clusters/prod` would have rendered the DEV LAN (192.168.1.0/24) onto the prod LAN (192.168.2.0/24)
|
||||
# — a silent, cross-environment IP collision. They are knobs now. Base defaults ARE the dev values
|
||||
# (same convention as GATEWAY_VIP above: the base default is the known-good working value), so the
|
||||
# dev overlay restates them verbatim and a prod overlay MUST override EVERY knob in this block
|
||||
# before `flux bootstrap --path=clusters/prod`.
|
||||
#
|
||||
# Substitution coverage (verify before adding a knob here): every path that consumes these declares
|
||||
# postBuild.substituteFrom cluster-vars — platform (K3S_API_HOST), platform-config
|
||||
# (CILIUM_LB_POOL_*, S3_BACKUP_ENDPOINT), crafty (CRAFTY_L4_VIP), apps-wave-0 (S3_BACKUP_ENDPOINT),
|
||||
# velero (S3_BACKUP_ENDPOINT), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an UNSUBSTITUTED path would
|
||||
# ship the literal string into the cluster.
|
||||
|
||||
# k3s API endpoint Cilium's kube-proxy replacement dials (platform/cilium/helmrelease.yaml).
|
||||
# [parity] MUST equal the Ansible bootstrap's k3s_api_host (infra/ansible/roles/cilium/defaults/
|
||||
# main.yml -> cilium-values.yaml.j2, already inventory-parameterized). Wrong value = Cilium cannot
|
||||
# reach the API server on adopt and the CNI breaks. Port stays 6443 in both envs, so it is NOT a knob.
|
||||
K3S_API_HOST: "192.168.1.50"
|
||||
|
||||
# CiliumLoadBalancerIPPool `default-pool` (platform-config/cilium/l2-ip-pool.yaml): the VIP range
|
||||
# L2-announced on the flat LAN. Must sit OUTSIDE the node IPs and any DHCP scope, and must match the
|
||||
# tofu reservation (cilium_l2_vip_range). INVARIANT: ${GATEWAY_VIP} must be INSIDE this range and in
|
||||
# practice EQUAL to START — the `shared` Gateway pins no address, so LB-IPAM hands it the pool's
|
||||
# first free IP. (⚠ the operator-supplied PROD values currently break this; see l2-ip-pool.yaml and
|
||||
# the flagged block in clusters/prod/kustomization.yaml.)
|
||||
CILIUM_LB_POOL_START: "192.168.1.53"
|
||||
CILIUM_LB_POOL_STOP: "192.168.1.60"
|
||||
|
||||
# Pinned VIPs carved out of that pool by name (lbipam.cilium.io/ips). These are L4 workloads the
|
||||
# HTTPS Gateway cannot express (raw TCP/UDP), so they hold their own stable address:
|
||||
UNIFI_L4_VIP: "192.168.1.54" # UniFi controller VM: inform/STUN/adoption (apps/vms/unifi)
|
||||
CRAFTY_L4_VIP: "192.168.1.55" # Crafty: Minecraft Java (TCP) + Bedrock (UDP) game ports
|
||||
|
||||
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
|
||||
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
|
||||
# nextcloud-db, semaphore-db) and Velero's backupStorageLocation s3Url. Full URL incl. scheme+port.
|
||||
# Prod needs its OWN target: a backup that lands on the dev LAN's MinIO is not off-cluster DR.
|
||||
S3_BACKUP_ENDPOINT: "http://192.168.1.9:9000"
|
||||
|
||||
# --- storage (ADR-0003) ---
|
||||
LONGHORN_DEFAULT_REPLICA_COUNT: "3" # default StorageClass replica count (3 on 3 nodes)
|
||||
|
||||
@@ -148,12 +148,44 @@ patches:
|
||||
- op: replace
|
||||
path: /data/ACME_CLUSTER_ISSUER
|
||||
value: "letsencrypt-prod"
|
||||
# --- dev LAN addressing (192.168.1.0/24 on the Proxmox LAN) -----------------------------
|
||||
# EVERY knob in cluster-vars' "LAN addressing" block is restated here EXPLICITLY, even where it
|
||||
# equals the base default. These values are what the LIVE dev cluster is running right now, so
|
||||
# the substituted output is byte-identical to the pre-parameterization hardcoded manifests
|
||||
# (verified 2026-08-31). Never let this block drift from the live cluster.
|
||||
#
|
||||
# Split-horizon DNS target: the Cilium-L2 VIP of the `shared` Gateway on the Proxmox LAN
|
||||
# (base default 10.73.0.240 was the old libvirt VIP). coredns-custom answers
|
||||
# (= CILIUM_LB_POOL_START, the pool's first address). coredns-custom answers
|
||||
# *.home.keithjackson.dev with this so in-cluster OIDC clients reach Keycloak via the Gateway.
|
||||
- op: replace
|
||||
path: /data/GATEWAY_VIP
|
||||
value: "192.168.1.53"
|
||||
# k3s API endpoint Cilium's kube-proxy replacement dials — the primary control-plane node
|
||||
# (k3s-1). [parity] equals the Ansible inventory's k3s_api_host for dev.
|
||||
- op: replace
|
||||
path: /data/K3S_API_HOST
|
||||
value: "192.168.1.50"
|
||||
# Cilium LB IP pool = the tofu reservation cilium_l2_vip_range .53-.60, outside the node IPs
|
||||
# (.50/.51/.52) and outside the DHCP scope. .53 Gateway, .54 unifi, .55 crafty; .56-.60 free.
|
||||
- op: replace
|
||||
path: /data/CILIUM_LB_POOL_START
|
||||
value: "192.168.1.53"
|
||||
- op: replace
|
||||
path: /data/CILIUM_LB_POOL_STOP
|
||||
value: "192.168.1.60"
|
||||
# Pinned L4 VIPs out of that pool (lbipam.cilium.io/ips). Changing either MOVES a live device-
|
||||
# facing endpoint: unifi is the inform/STUN target every adopted UniFi device holds.
|
||||
- op: replace
|
||||
path: /data/UNIFI_L4_VIP
|
||||
value: "192.168.1.54"
|
||||
- op: replace
|
||||
path: /data/CRAFTY_L4_VIP
|
||||
value: "192.168.1.55"
|
||||
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
|
||||
# barmanObjectStore and Velero's backupStorageLocation write to.
|
||||
- op: replace
|
||||
path: /data/S3_BACKUP_ENDPOINT
|
||||
value: "http://192.168.1.9:9000"
|
||||
# dev mirrors prod's 3-node topology, so the replicated tier keeps 3 replicas.
|
||||
- op: replace
|
||||
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
|
||||
|
||||
@@ -49,8 +49,68 @@ patches:
|
||||
- op: replace
|
||||
path: /data/VELERO_RETENTION
|
||||
value: "720h"
|
||||
# ==========================================================================================
|
||||
# PROD LAN ADDRESSING (192.168.2.0/24). Nodes: k3s-prod-1/2/3 = .10 / .11 / .12.
|
||||
# ==========================================================================================
|
||||
# These knobs were HARDCODED to the dev LAN inside base manifests until 2026-08-31; prod would
|
||||
# have come up ANNOUNCING DEV ADDRESSES (192.168.1.x) on the prod LAN. They are knobs now, and
|
||||
# this overlay supplies every one of them. Anything the operator has NOT yet decided stays an
|
||||
# INVALID `TBD-PROD-*` placeholder on purpose: base defaults are the DEV values, so an OMITTED
|
||||
# op means prod silently inherits a dev address — far worse than a failed apply. `TBD-PROD-*`
|
||||
# is not a valid IP/URL, so Cilium CRD validation / LB-IPAM / CNPG / Velero reject it LOUDLY.
|
||||
#
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# ADDRESS PLAN (operator, 2026-08-31) — see infra/PROD-PROVISIONING.md §4 for the full table.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# nodes 192.168.2.10 / .11 / .12 (Ansible inventory)
|
||||
# control-plane VIP 192.168.2.9 (Ansible group_vars/k3s_prod — NOT here)
|
||||
# ingress Gateway VIP 192.168.2.20 (GATEWAY_VIP, below)
|
||||
# Cilium LB pool 192.168.2.20 - .30 (CILIUM_LB_POOL_*, below)
|
||||
#
|
||||
# CONTAINMENT INVARIANT SATISFIED: GATEWAY_VIP (.20) IS CILIUM_LB_POOL_START, mirroring dev
|
||||
# (.53 of .53-.60). This matters because the `shared` Gateway (platform/gateway/gateway.yaml)
|
||||
# pins NOTHING — no `lbipam.cilium.io/ips`, no spec.addresses (unlike platform/crafty and
|
||||
# apps/vms/unifi, which DO pin) — so Cilium LB-IPAM assigns it the FIRST FREE address of a
|
||||
# matching pool block. Had GATEWAY_VIP stayed outside every pool (the original .21-.30
|
||||
# proposal), the Gateway would have come up on .21 while split-horizon coredns-custom kept
|
||||
# answering *.home.keithjackson.dev with .20 — an address nothing announces — blackholing
|
||||
# in-cluster hairpin traffic (OIDC clients -> Keycloak/Grafana) while looking healthy.
|
||||
# KEEP GATEWAY_VIP == CILIUM_LB_POOL_START when changing either.
|
||||
# The control-plane VIP (.9) is deliberately OUTSIDE this pool: kube-vip floats it, and
|
||||
# LB-IPAM must never be able to hand it to a Service.
|
||||
- op: replace
|
||||
path: /data/GATEWAY_VIP
|
||||
value: "192.168.2.20"
|
||||
# Cilium LB IP pool, operator-supplied: 192.168.2.20-.30 (START == GATEWAY_VIP, see above).
|
||||
# Mirror this range into the prod tofu reservation (cilium_l2_vip_range) if prod ever gets one.
|
||||
- op: replace
|
||||
path: /data/CILIUM_LB_POOL_START
|
||||
value: "192.168.2.20"
|
||||
- op: replace
|
||||
path: /data/CILIUM_LB_POOL_STOP
|
||||
value: "192.168.2.30"
|
||||
# --- still UNDECIDED -> loud placeholders (must be real before `flux bootstrap`) ------------
|
||||
# k3s API endpoint Cilium's kube-proxy replacement dials. This is the prod CONTROL-PLANE VIP
|
||||
# (kube-vip), NOT .20 (the ingress VIP) and NOT the single node address .10. It is owned
|
||||
# Ansible-side (prod inventory k3s_api_host) and MUST equal that value — [parity] contract in
|
||||
# platform/cilium/helmrelease.yaml. Not yet decided.
|
||||
- op: replace
|
||||
path: /data/K3S_API_HOST
|
||||
value: "192.168.2.10"
|
||||
# Pinned L4 VIPs, both carved out of the pool by lbipam.cilium.io/ips. Not yet assigned for
|
||||
# prod. Leaving the dev literals in place would hand a prod LoadBalancer a dev-LAN address, so
|
||||
# they stay placeholders; whatever is chosen MUST fall inside [POOL_START, POOL_STOP] and not
|
||||
# collide with the Gateway VIP or the node IPs (.10/.11/.12).
|
||||
- op: replace
|
||||
path: /data/UNIFI_L4_VIP
|
||||
value: "192.168.2.21"
|
||||
- op: replace
|
||||
path: /data/CRAFTY_L4_VIP
|
||||
value: "192.168.2.22"
|
||||
# Off-cluster DR target. Dev's versitygw/MinIO (192.168.1.9:9000) sits on the DEV side of the
|
||||
# network — a prod backup written there is not off-cluster DR and would mix two environments'
|
||||
# PITR streams in one bucket. Operator supplies the prod endpoint (full URL incl. scheme+port).
|
||||
- op: replace
|
||||
path: /data/S3_BACKUP_ENDPOINT
|
||||
value: "TBD-PROD-S3-BACKUP-ENDPOINT"
|
||||
# TODO(WS-INFRA, Phase 7): pin prod node placement once the bare-metal role labels exist.
|
||||
# TODO(WS-NET, Phase 7): set GATEWAY_VIP to the prod LAN ingress VIP (add a `replace` op here)
|
||||
# at the same time the prod Cilium LB IP pool range is supplied — the split-horizon
|
||||
# coredns-custom (platform/coredns) answers *.home.keithjackson.dev with ${GATEWAY_VIP}, so
|
||||
# the prod value must match the prod pool's Gateway address. Base default is the dev VIP.
|
||||
@@ -5,3 +5,6 @@ _artifacts/
|
||||
|
||||
# Downloaded pinned tool binaries (run.sh may fetch chainsaw/kubectl here).
|
||||
.bin/
|
||||
|
||||
# Python bytecode from lib/ (status.py imports coverage.py); never committed.
|
||||
__pycache__/
|
||||
@@ -18,6 +18,7 @@ behavioral / L3 fault-injection** (this harness).
|
||||
./run.sh --requirements R-NET-01,R-NET-06 # merge-gate subset (tagged)
|
||||
./run.sh --layer l2 # one layer: l1 | l2 | l3 | meta | all
|
||||
./run.sh --coverage-only # regenerate the coverage report (no cluster)
|
||||
./run.sh --status # derived requirement-STATUS report (no cluster)
|
||||
./run.sh --lint # structural-validate test defs (no cluster)
|
||||
./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
||||
./run.sh --require-all # phase-exit gate: every requirement must be covered
|
||||
@@ -37,6 +38,33 @@ behavioral / L3 fault-injection** (this harness).
|
||||
used to be here; it now has a real L3 test — `tests/l3-fault-injection/node-loss` — and
|
||||
is covered, not exempted.)
|
||||
|
||||
### Coverage vs status — two different questions
|
||||
|
||||
`coverage.md` answers **"does a requirement-tagged test EXIST for R-x?"** (R-TEST-04).
|
||||
`status.md` (`./run.sh --status`, generator `lib/status.py`) answers the operator's
|
||||
question — **"what is the real state of each requirement given what is actually in the
|
||||
repo?"** — deriving three orthogonal axes per requirement:
|
||||
|
||||
1. **implementation** — is there a tracked artifact naming the requirement?
|
||||
2. **reconciliation** — is that artifact reachable by walking `resources:` from
|
||||
`clusters/<env>/kustomization.yaml` and expanding every Flux `Kustomization`'s
|
||||
`spec.path`? This is what separates **authored** from **actually reconciled**: a
|
||||
commented-out `resources:` entry (e.g. `platform/image-automation`) is correctly
|
||||
reported as in-git-but-not-on-any-cluster.
|
||||
3. **verification** — is the tagged test EXECUTABLE, or a TODO scaffold? Coverage counts
|
||||
both as "covered"; status does not. TODO markers in a body always outrank an
|
||||
`executable: "true"` annotation, so ambiguity never upgrades a test.
|
||||
|
||||
**It deliberately refuses to derive proof-of-pass.** Nothing tracked in git records a test
|
||||
result — `_artifacts/` and `results/` are git-ignored and DECISIONS-LOG.md prose is not a
|
||||
signal — so every requirement's proof state is reported as `not-derivable` rather than
|
||||
defaulting to green. A status report that overstates completion is worse than none
|
||||
(`agents/definition-of-done.md`). Closing that gap needs a **committed, machine-readable
|
||||
run ledger**; until then the strongest claim the report makes is *wired and testable*.
|
||||
`--strict-coverage` also applies status's hygiene gate (registry↔spec drift,
|
||||
annotation/body conflicts, a safety-critical requirement whose only L3 test is a scaffold).
|
||||
Exit codes follow `ci/lib.sh`: `0` pass / `1` fail / `3` skipped — a skip is never green.
|
||||
|
||||
`run.sh` is the **only** entrypoint. Local runs and the in-cluster CI Chainsaw Job call
|
||||
the *same* script with the same config, so results are identical.
|
||||
|
||||
@@ -46,6 +74,8 @@ the *same* script with the same config, so results are identical.
|
||||
| `_artifacts/report.junit.xml` | JUnit — Chainsaw results, or a **skipped-scaffold** shim when no cluster | CI gate |
|
||||
| `_artifacts/coverage/coverage.md` | Human requirement-coverage report | PR reviewers / logs |
|
||||
| `_artifacts/coverage/coverage.json` | Machine coverage: `{requirement: [tests]}`, gaps, safety gaps | CI gate |
|
||||
| `_artifacts/status/status.md` | Human requirement-STATUS report derived from repo state | operator / PM |
|
||||
| `_artifacts/status/status.json` | Machine status: implementation / reconciliation / verification per requirement | tooling |
|
||||
|
||||
## Reproducibility contract (R-TEST-02 — what the harness guarantees)
|
||||
- **Single command.** `./run.sh` (or a tagged/layer subset) runs everything and emits the
|
||||
@@ -74,6 +104,7 @@ requirements/registry.yaml mirror of EVERY spec R-* ID (coverage enumerates fr
|
||||
lib/
|
||||
common.sh tagging convention + test discovery/selection helpers
|
||||
coverage.py requirement-coverage generator (stdlib only)
|
||||
status.py requirement-STATUS generator (stdlib only; --selftest)
|
||||
emit_skipped_junit.py Phase-0 skipped-JUnit shim (no cluster)
|
||||
tests/
|
||||
l1-rendering/ l2-behavioral/ l3-fault-injection/ meta/ (see tests/README.md)
|
||||
|
||||
@@ -64,6 +64,22 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: [pods/log]
|
||||
verbs: [get] # read fault/probe Job logs on failure
|
||||
# ---- Storage-tier precondition gate (READ-ONLY). Every storage test self-skips on the
|
||||
# absence of the `longhorn` StorageClass rather than failing: data-survival (R-STATE-01),
|
||||
# node-loss (R-STATE-02), dr-restore (R-STATE-03), vm-data-survival / vm-lifecycle /
|
||||
# vm-default-deny (R-PLAT-03). Without this read the gate cannot tell "absent" from
|
||||
# "forbidden" and would skip for the wrong reason.
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [storageclasses]
|
||||
verbs: [get, list]
|
||||
# ---- Controller-readiness preconditions (READ-ONLY). dr-restore (R-STATE-03) skips cleanly
|
||||
# unless the Velero Deployment and its node-agent DaemonSet (kopia File System Backup, the
|
||||
# volume-DATA path) are actually ready — a backup path that is not up is an absent
|
||||
# precondition, not a failure. No write verbs: tests never scale or patch a platform
|
||||
# controller.
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments, daemonsets]
|
||||
verbs: [get, list, watch]
|
||||
# ---- Probe / fault Jobs (R-TLS-01/03 tls-probe; R-NET-06 tunnel stop/start)
|
||||
# `create` cannot be constrained by resourceNames, and the fault Job names are
|
||||
# fixed per-test (tunnel-stop/tunnel-start/tunnel-start-finally); scoped by the
|
||||
@@ -103,10 +119,29 @@ rules:
|
||||
# resources: [imagepolicies, imagerepositories]
|
||||
# verbs: [get, patch]
|
||||
#
|
||||
# ---- R-STATE-03 (DR restore): trigger a Velero restore
|
||||
# - apiGroups: [velero.io]
|
||||
# resources: [restores, backups]
|
||||
# verbs: [create, get, list, watch]
|
||||
# ---- R-STATE-03 (DR restore, tests/l3-fault-injection/dr-restore): the drill creates a
|
||||
# Velero Backup of its OWN scratch namespace, destroys that namespace + its PV, creates a
|
||||
# Restore into a fresh namespace, and (in `finally`) issues a DeleteBackupRequest so the
|
||||
# objects it wrote are removed from the off-cluster bucket again. `create` cannot be
|
||||
# constrained by resourceNames; the blast radius is bounded by the fact that every object
|
||||
# the test creates or deletes is named `<its-own-chainsaw-namespace>-dr-*`.
|
||||
- apiGroups: [velero.io]
|
||||
resources: [backups, restores, deletebackuprequests]
|
||||
verbs: [create, delete, get, list, watch]
|
||||
# Read-only evidence: the BSL is the off-cluster target whose Available phase + endpoint the
|
||||
# test asserts; schedules prove backups are SCHEDULED (not one-off); podvolumebackups /
|
||||
# podvolumerestores prove the kopia snapshot of the volume DATA reached/returned from the
|
||||
# remote repository, and are dumped on failure for diagnosis.
|
||||
- apiGroups: [velero.io]
|
||||
resources: [backupstoragelocations, schedules, podvolumebackups, podvolumerestores]
|
||||
verbs: [get, list, watch]
|
||||
# R-STATE-03 needs `delete` on the PV, not merely read: the proof that the restore came from
|
||||
# OFF-cluster is that no in-cluster copy of the data survived. The test asserts its own PV is
|
||||
# gone, and force-deletes it if a Retain policy or a stuck finalizer left it behind. It only
|
||||
# ever names the PV bound to the PVC it created itself.
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumes]
|
||||
verbs: [get, list, delete]
|
||||
#
|
||||
# ---- R-GITOPS-03 (drift): mutate a reconciled resource + observe Flux revert
|
||||
# - apiGroups: [kustomize.toolkit.fluxcd.io]
|
||||
|
||||
@@ -47,7 +47,7 @@ conf_dirs_for_requirements() {
|
||||
done < <(conf_all_test_files) | sort -u
|
||||
}
|
||||
|
||||
# Directories for a given layer (subtree): l1 | l2 | l3 | meta | all.
|
||||
# Directories for a given layer (subtree): l0 | l1 | l2 | l3 | meta | all.
|
||||
# Accepts a comma/space separated LIST of layers (e.g. "l2,l3") and returns the UNION of
|
||||
# their test dirs, deduplicated. A single layer keeps its prior behavior (backward compatible);
|
||||
# this is what lets `run.sh --layer l2,l3` (the ci/conformance.sh L2/L3 stage) select both
|
||||
@@ -58,6 +58,7 @@ conf_dirs_for_layer() {
|
||||
for layer in ${layers//,/ }; do
|
||||
case "$layer" in
|
||||
all) roots=("${CONF_ROOT}/tests"); break ;;
|
||||
l0) roots+=("${CONF_ROOT}/tests/l0-static") ;;
|
||||
l1) roots+=("${CONF_ROOT}/tests/l1-rendering") ;;
|
||||
l2) roots+=("${CONF_ROOT}/tests/l2-behavioral") ;;
|
||||
l3) roots+=("${CONF_ROOT}/tests/l3-fault-injection") ;;
|
||||
|
||||
@@ -115,7 +115,9 @@ def parse_test(path: Path) -> dict:
|
||||
# Layer inferred from path as a fallback / cross-check.
|
||||
rel = str(path)
|
||||
if not layer:
|
||||
if "l1-rendering" in rel:
|
||||
if "l0-static" in rel:
|
||||
layer = "L0"
|
||||
elif "l1-rendering" in rel:
|
||||
layer = "L1"
|
||||
elif "l2-behavioral" in rel:
|
||||
layer = "L2"
|
||||
|
||||
@@ -33,7 +33,8 @@ def collect(path: Path) -> tuple[str, list[str], str]:
|
||||
reqs = sorted({m.group(1).upper() for m in
|
||||
(LABEL_LINE_RE.match(l) for l in text.splitlines()) if m})
|
||||
rel = str(path)
|
||||
layer = ("L1" if "l1-rendering" in rel else "L2" if "l2-behavioral" in rel
|
||||
layer = ("L0" if "l0-static" in rel
|
||||
else "L1" if "l1-rendering" in rel else "L2" if "l2-behavioral" in rel
|
||||
else "L3" if "l3-fault-injection" in rel else "META")
|
||||
return name, reqs, layer
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the L1 *rendering* conformance checks OFFLINE (no cluster) and emit JUnit.
|
||||
"""Run the CLUSTER-INDEPENDENT conformance checks OFFLINE and emit JUnit.
|
||||
|
||||
docs/14: "L1 rendering assertions run in CI without a cluster." L1 verifies the
|
||||
ServiceDefinition -> rendered-resources mapping against golden expectations; unlike L2/L3
|
||||
it needs no live cluster. Each l1-rendering test dir carries a `render-check.sh` (renderer
|
||||
+ golden diff + structural asserts). run.sh invokes this for `--layer l1` so those tests
|
||||
report a REAL pass/fail instead of being skipped with the cluster-gated layers.
|
||||
docs/14 puts two layers in CI with no cluster:
|
||||
* L0 static/policy -- "manifests/ServiceDefinitions obey invariants" (tests/l0-static/,
|
||||
each dir carrying a `check.sh`).
|
||||
* L1 rendering -- ServiceDefinition -> rendered resources vs golden expectations
|
||||
(tests/l1-rendering/, each dir carrying a `render-check.sh`).
|
||||
Both are executed here so they report a REAL pass/fail instead of being skipped with the
|
||||
cluster-gated layers. run.sh invokes this for `--layer l0`, `--layer l1`, and as the
|
||||
offline leg of a full run.
|
||||
|
||||
Pure standard library (matches the harness's no-pip-dependency posture); the render checks
|
||||
themselves shell out to catalog/render.py (the renderer's PyYAML dependency lives there).
|
||||
Pure standard library (matches the harness's no-pip-dependency posture); the checks
|
||||
themselves shell out (render checks to catalog/render.py, whose PyYAML dependency lives
|
||||
there; L0 checks to git + PyYAML).
|
||||
|
||||
Usage: run_l1_offline.py --junit <out.xml> [--label-prefix P] <test-dir> [<test-dir> ...]
|
||||
Exit 0 iff every selected render check passes.
|
||||
Exit 0 iff every selected offline check passes.
|
||||
|
||||
The per-dir check script is whichever of `render-check.sh` / `check.sh` the directory
|
||||
carries; a dir with neither is reported SKIPPED (never a pass).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,6 +32,19 @@ from xml.dom import minidom
|
||||
|
||||
LABEL_LINE_RE_TMPL = r"^\s*{prefix}/(R-[A-Z0-9]+-[0-9]+)\s*:\s*[\"']?true"
|
||||
|
||||
# The offline check script a test dir may carry, in preference order.
|
||||
CHECK_SCRIPTS = ("render-check.sh", "check.sh")
|
||||
|
||||
|
||||
def layer_of(d: Path) -> str:
|
||||
"""Layer for the JUnit properties, from the subtree the test lives in."""
|
||||
rel = str(d)
|
||||
if "l0-static" in rel:
|
||||
return "L0"
|
||||
if "l1-rendering" in rel:
|
||||
return "L1"
|
||||
return "OFFLINE"
|
||||
|
||||
|
||||
def collect_meta(test_yaml: Path, prefix: str):
|
||||
text = test_yaml.read_text() if test_yaml.exists() else ""
|
||||
@@ -44,10 +64,11 @@ def main() -> int:
|
||||
|
||||
cases = [] # (name, reqs, dir, rc, output)
|
||||
for d in args.dirs:
|
||||
check = d / "render-check.sh"
|
||||
check = next((d / s for s in CHECK_SCRIPTS if (d / s).exists()), None)
|
||||
name, reqs = collect_meta(d / "chainsaw-test.yaml", args.label_prefix)
|
||||
if not check.exists():
|
||||
cases.append((name, reqs, d, 3, f"no render-check.sh in {d}"))
|
||||
if check is None:
|
||||
cases.append((name, reqs, d, 3,
|
||||
f"no offline check script ({' or '.join(CHECK_SCRIPTS)}) in {d}"))
|
||||
continue
|
||||
proc = subprocess.run(
|
||||
["bash", str(check)], capture_output=True, text=True
|
||||
@@ -61,7 +82,7 @@ def main() -> int:
|
||||
suite = ET.Element(
|
||||
"testsuite",
|
||||
{
|
||||
"name": "home-k3s-conformance L1 (offline render)",
|
||||
"name": "home-k3s-conformance L0/L1 (offline)",
|
||||
"tests": str(len(cases)),
|
||||
"failures": str(failures),
|
||||
"errors": "0",
|
||||
@@ -72,19 +93,21 @@ def main() -> int:
|
||||
tc = ET.SubElement(
|
||||
suite,
|
||||
"testcase",
|
||||
{"classname": f"L1:{','.join(reqs) if reqs else 'UNTAGGED'}", "name": name},
|
||||
{"classname": f"{layer_of(d)}:{','.join(reqs) if reqs else 'UNTAGGED'}",
|
||||
"name": name},
|
||||
)
|
||||
props = ET.SubElement(tc, "properties")
|
||||
ET.SubElement(props, "property", {"name": "requirements", "value": ",".join(reqs)})
|
||||
ET.SubElement(props, "property", {"name": "layer", "value": "L1"})
|
||||
ET.SubElement(props, "property", {"name": "layer", "value": layer_of(d)})
|
||||
ET.SubElement(props, "property", {"name": "path", "value": str(d)})
|
||||
if rc == 3:
|
||||
ET.SubElement(tc, "skipped", {"message": out or "no render-check.sh"})
|
||||
ET.SubElement(tc, "skipped", {"message": out or "no offline check script"})
|
||||
elif rc != 0:
|
||||
fail = ET.SubElement(tc, "failure", {"message": f"render-check exit {rc}"})
|
||||
fail = ET.SubElement(tc, "failure", {"message": f"offline check exit {rc}"})
|
||||
fail.text = out
|
||||
status = "PASS" if rc == 0 else ("SKIP" if rc == 3 else "FAIL")
|
||||
print(f"[l1-offline] {status:4s} {name} ({','.join(reqs) or 'untagged'})",
|
||||
print(f"[offline] {layer_of(d):3s} {status:4s} {name} "
|
||||
f"({','.join(reqs) or 'untagged'})",
|
||||
file=sys.stderr)
|
||||
if rc not in (0, 3):
|
||||
print(out, file=sys.stderr)
|
||||
@@ -103,7 +126,7 @@ def main() -> int:
|
||||
args.junit.parent.mkdir(parents=True, exist_ok=True)
|
||||
xml = minidom.parseString(ET.tostring(root, "utf-8")).toprettyxml(indent=" ")
|
||||
args.junit.write_text(xml)
|
||||
print(f"[l1-offline] wrote JUnit ({len(cases)} case(s), {failures} failure(s)) -> "
|
||||
print(f"[offline] wrote JUnit ({len(cases)} case(s), {failures} failure(s)) -> "
|
||||
f"{args.junit}", file=sys.stderr)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
Executable
+941
@@ -0,0 +1,941 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Requirement STATUS report — derived from repository state, not from a task board.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`lib/coverage.py` answers exactly one question: *does a requirement-tagged test exist
|
||||
for R-x?* (R-TEST-04). That is necessary but not sufficient: a requirement can have a
|
||||
tagged test that is a pure TODO scaffold, and can have implementing manifests that are
|
||||
authored in git but never reconciled by Flux. Both read as "covered"/"delivered" while
|
||||
nothing is actually true of the cluster.
|
||||
|
||||
This generator derives, from the REPOSITORY ALONE, three orthogonal axes per requirement:
|
||||
|
||||
1. IMPLEMENTATION — is there implementing config in the repo, and is it reachable
|
||||
from a Flux Kustomization entrypoint (i.e. actually reconciled)?
|
||||
2. VERIFICATION — is there a requirement-tagged test, at which layer, and is it an
|
||||
EXECUTABLE test or a TODO scaffold?
|
||||
3. PROOF — has that test actually passed? *** DELIBERATELY NOT DERIVED. ***
|
||||
|
||||
WHAT THIS TOOL REFUSES TO DERIVE (and why)
|
||||
------------------------------------------
|
||||
PROOF-OF-PASS IS NOT DERIVABLE FROM REPO STATE. Nothing tracked in git records a test
|
||||
result: `conformance/_artifacts/` and `results/` are git-ignored, and the only prose
|
||||
record of a green run lives in DECISIONS-LOG.md — hand-maintained, unstructured, and
|
||||
exactly the kind of source the operator has ruled out. The project's own culture is that
|
||||
"merged" is never "done" (agents/definition-of-done.md, R-TEST-03), so this report will
|
||||
NOT infer, guess, or default a requirement to green. Every requirement's proof state is
|
||||
reported as `not-derivable`, and the headline says so. If an untracked JUnit artifact
|
||||
happens to be lying in the workspace it is surfaced ONLY as a quarantined advisory with
|
||||
its mtime — never counted, never mixed into a status.
|
||||
|
||||
The honest fix is a committed, machine-readable run ledger; until that exists, the most
|
||||
this report can say is "wired and testable", never "proven".
|
||||
|
||||
READS (all read-only)
|
||||
requirements/registry.yaml the authoritative R-* mirror (via lib/coverage.py)
|
||||
tests/**/chainsaw-test.yaml requirement tags + layer + executable annotation
|
||||
clusters/<env>/ Flux entrypoint -> the reconciliation graph
|
||||
`git ls-files` TRACKED files only; untracked scratch is not repo state
|
||||
../home-network-k3s/docs/00-vision-and-scope.md OPTIONAL registry<->spec parity check
|
||||
|
||||
WRITES (under --out, default _artifacts/status/, git-ignored like coverage/)
|
||||
status.json machine-readable, one record per requirement + per-spec rollup
|
||||
status.md human-readable report
|
||||
|
||||
EXIT CODES (ci/lib.sh convention: 0 pass / 1 fail / 3 skipped-not-green)
|
||||
0 report generated.
|
||||
1 a derivation failed, or --strict found a defect this tool owns (registry/spec
|
||||
drift, a requirement-tag hygiene problem, a safety-critical requirement whose only
|
||||
L3 test is a scaffold).
|
||||
3 a requested check could not run (e.g. --spec-root given but absent). A SKIP IS
|
||||
NEVER COUNTED AS GREEN.
|
||||
|
||||
Pure standard library (no PyYAML), matching the harness's no-pip-dependency posture
|
||||
(lib/coverage.py); the file shapes parsed here are line-constrained and documented at
|
||||
each parser. `catalog/render.py` owns the one PyYAML dependency; the harness does not.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_LIB = Path(__file__).resolve().parent
|
||||
if str(_LIB) not in sys.path:
|
||||
sys.path.insert(0, str(_LIB))
|
||||
# Importing a sibling module would otherwise litter conformance/lib/__pycache__/ into the
|
||||
# working tree; the lint stage scans untracked files, so keep the tree clean.
|
||||
sys.dont_write_bytecode = True
|
||||
import coverage as cov # noqa: E402 — reuse the registry/test parsers, don't fork them
|
||||
|
||||
CONF_ROOT = _LIB.parent
|
||||
REPO_ROOT = CONF_ROOT.parent
|
||||
DEFAULT_SPEC_ROOT = REPO_ROOT.parent / "home-network-k3s"
|
||||
|
||||
RID_RE = cov.RID_RE
|
||||
LABEL_PREFIX = cov.LABEL_PREFIX
|
||||
|
||||
# Flux Kustomization identity. A `kind: Kustomization` doc is only a *Flux* entrypoint if
|
||||
# it also carries the kustomize.toolkit apiVersion — plain kustomize overlays share the kind.
|
||||
FLUX_API = "kustomize.toolkit.fluxcd.io"
|
||||
|
||||
# Files excluded from the IMPLEMENTATION scan, with the same reasoning ci/touched-
|
||||
# requirements.sh applies to its content scan:
|
||||
# *.md — an R-ID in prose is a REFERENCE, not an implementing artifact.
|
||||
# (DECISIONS-LOG.md alone names nearly every requirement.)
|
||||
# conformance/tests — those are the TESTS; they are the VERIFICATION axis, counted there.
|
||||
# ci/tests — gate unit-test fixtures embed example R-IDs as DATA.
|
||||
# registry.yaml — the requirement mirror itself, not an implementation of anything.
|
||||
IMPL_EXCLUDE_PREFIXES = ("conformance/tests/", "ci/tests/")
|
||||
IMPL_EXCLUDE_PATHS = ("conformance/requirements/registry.yaml",)
|
||||
|
||||
|
||||
# ============================================================================ git
|
||||
def tracked_files(root: Path) -> list[str]:
|
||||
"""Repo state == TRACKED files. Untracked scratch must never count as delivery."""
|
||||
out = subprocess.run(["git", "-C", str(root), "ls-files"],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return [l for l in out.splitlines() if l]
|
||||
|
||||
|
||||
# ================================================================ kustomize walk
|
||||
# Parser contract: a kustomization.yaml in this repo uses ONLY a block `resources:` list
|
||||
# of ` - <path>` entries (verified: `resources` is the sole reference key across all 30
|
||||
# kustomization.yaml files). Entries are plain scalars, optionally followed by a `#`
|
||||
# comment. A COMMENTED-OUT entry is not a list item and is therefore correctly EXCLUDED —
|
||||
# that is the whole point: `platform/kustomization.yaml` comments out velero and
|
||||
# image-automation, so those directories are authored but not reconciled through it.
|
||||
_RES_KEY_RE = re.compile(r"^resources:\s*$")
|
||||
_TOP_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*:")
|
||||
# NOTE: list items may be indented (` - x`) or flush-left (`- x`, which is what
|
||||
# `catalog/render.py --emit-waves` generates for apps/wave-*/kustomization.yaml).
|
||||
_RES_ITEM_RE = re.compile(r"^\s*-\s+(?P<v>[^#\s]+)\s*(?:#.*)?$")
|
||||
|
||||
|
||||
def kustomize_resources(kfile: Path) -> list[str]:
|
||||
"""Return the literal `resources:` entries of a kustomization.yaml."""
|
||||
items: list[str] = []
|
||||
in_res = False
|
||||
for raw in kfile.read_text().splitlines():
|
||||
if _RES_KEY_RE.match(raw):
|
||||
in_res = True
|
||||
continue
|
||||
if in_res:
|
||||
m = _RES_ITEM_RE.match(raw)
|
||||
if m:
|
||||
items.append(m.group("v"))
|
||||
continue
|
||||
if raw.strip() == "" or raw.lstrip().startswith("#"):
|
||||
continue
|
||||
if _TOP_KEY_RE.match(raw):
|
||||
in_res = False
|
||||
return items
|
||||
|
||||
|
||||
# Flux Kustomization `spec.path` extraction. Constraint: `path:` appears at spec-level
|
||||
# indent inside a doc that carries the Flux apiVersion. Docs are split on a lone `---`.
|
||||
# Inline `patch: |-` blocks (clusters/dev) carry Flux Kustomizations WITHOUT a path, so
|
||||
# they contribute nothing — correct, they are patches, not entrypoints.
|
||||
_DOC_SPLIT_RE = re.compile(r"^---\s*$", re.M)
|
||||
_PATH_RE = re.compile(r"^\s{2,6}path:\s*(\S+)\s*$", re.M)
|
||||
_NAME_RE = re.compile(r"^\s{2,6}name:\s*(\S+)\s*$", re.M)
|
||||
|
||||
|
||||
def flux_paths(yfile: Path) -> list[tuple[str, str]]:
|
||||
"""[(flux-kustomization-name, spec.path)] declared by this file."""
|
||||
found: list[tuple[str, str]] = []
|
||||
try:
|
||||
text = yfile.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return found
|
||||
if FLUX_API not in text:
|
||||
return found
|
||||
for doc in _DOC_SPLIT_RE.split(text):
|
||||
if FLUX_API not in doc or not re.search(r"^kind:\s*Kustomization\s*$", doc, re.M):
|
||||
continue
|
||||
pm = _PATH_RE.search(doc)
|
||||
if not pm:
|
||||
continue
|
||||
nm = _NAME_RE.search(doc)
|
||||
found.append((nm.group(1) if nm else "?", pm.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def reconciled_files(repo: Path, entrypoint: str) -> tuple[set[str], list[dict]]:
|
||||
"""Walk the Flux reconciliation graph from `clusters/<env>` and return
|
||||
|
||||
(set of repo-relative files Flux actually applies, list of Flux Kustomization nodes)
|
||||
|
||||
Algorithm: kustomize-walk a directory's `resources:`; a directory entry recurses, a
|
||||
file entry is applied. Every applied file is then scanned for Flux Kustomization
|
||||
objects; each one's `spec.path` is a NEW kustomize-walk root (that is how Flux
|
||||
expands the graph). Cycles are guarded (clusters/dev/sync.yaml points at its own dir).
|
||||
|
||||
This is what makes the difference between AUTHORED and RECONCILED derivable: a
|
||||
manifest that no walk reaches is in git but is not applied to any cluster.
|
||||
"""
|
||||
applied: set[str] = set()
|
||||
nodes: list[dict] = []
|
||||
seen_dirs: set[str] = set()
|
||||
pending: list[tuple[str, str]] = [("<entrypoint>", entrypoint)]
|
||||
|
||||
def walk_dir(rel: str) -> None:
|
||||
if rel in seen_dirs:
|
||||
return
|
||||
seen_dirs.add(rel)
|
||||
kfile = repo / rel / "kustomization.yaml"
|
||||
if not kfile.is_file():
|
||||
return
|
||||
applied.add(os.path.relpath(kfile, repo))
|
||||
for item in kustomize_resources(kfile):
|
||||
target = (repo / rel / item).resolve()
|
||||
try:
|
||||
trel = os.path.relpath(target, repo)
|
||||
except ValueError:
|
||||
continue
|
||||
if trel.startswith(".."):
|
||||
continue # outside the repo: not repo state
|
||||
if target.is_dir():
|
||||
walk_dir(trel)
|
||||
elif target.is_file():
|
||||
applied.add(trel)
|
||||
for name, p in flux_paths(target):
|
||||
pending.append((name, p))
|
||||
|
||||
while pending:
|
||||
owner, p = pending.pop()
|
||||
rel = os.path.normpath(p.lstrip("./")) if p not in (".", "./") else "."
|
||||
node = {"kustomization": owner, "path": p, "resolved": rel,
|
||||
"exists": (repo / rel).is_dir()}
|
||||
nodes.append(node)
|
||||
if node["exists"]:
|
||||
walk_dir(rel)
|
||||
|
||||
return applied, nodes
|
||||
|
||||
|
||||
# ====================================================== implementation evidence
|
||||
_COMMENT_LINE_RE = re.compile(r"^\s*#")
|
||||
|
||||
|
||||
def scan_implementation(repo: Path, files: list[str]) -> dict[str, list[dict]]:
|
||||
"""Map R-ID -> the tracked, non-doc files that name it.
|
||||
|
||||
Two strengths are recorded per file, mirroring ci/touched-requirements.sh rule 3:
|
||||
* a hit on a REAL line -> the artifact carries the ID (label/annotation/field);
|
||||
* a hit only in a `#` comment -> declared INTENT (a header saying what it serves).
|
||||
Comment-only evidence is kept (it is the dominant convention in this repo's
|
||||
manifests) but reported as such, so a reader can tell an implementation from a claim.
|
||||
"""
|
||||
out: dict[str, list[dict]] = {}
|
||||
for rel in files:
|
||||
if rel.endswith(".md") or rel in IMPL_EXCLUDE_PATHS:
|
||||
continue
|
||||
if rel.startswith(IMPL_EXCLUDE_PREFIXES):
|
||||
continue
|
||||
path = repo / rel
|
||||
try:
|
||||
text = path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if "R-" not in text:
|
||||
continue
|
||||
# A file is RECONCILABLE only if it is a Kubernetes manifest: a *.yaml with a
|
||||
# top-level `kind:`. Shell scripts / values files / READMEs under a GitOps root
|
||||
# are never applied by Flux, so counting them as "authored but not wired" would
|
||||
# manufacture a false gap.
|
||||
manifest = (rel.endswith((".yaml", ".yml"))
|
||||
and re.search(r"^kind:\s*\S", text, re.M) is not None)
|
||||
hits: dict[str, bool] = {} # rid -> seen on a non-comment line
|
||||
for line in text.splitlines():
|
||||
ids = RID_RE.findall(line)
|
||||
if not ids:
|
||||
continue
|
||||
real = not _COMMENT_LINE_RE.match(line)
|
||||
for rid in ids:
|
||||
hits[rid] = hits.get(rid, False) or real
|
||||
for rid, real in hits.items():
|
||||
out.setdefault(rid, []).append({"path": rel, "comment_only": not real,
|
||||
"manifest": manifest})
|
||||
for rid in out:
|
||||
out[rid].sort(key=lambda h: h["path"])
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================ verification
|
||||
_EXECUTABLE_RE = re.compile(
|
||||
r"^\s*" + re.escape(LABEL_PREFIX) + r"/executable:\s*[\"']?true", re.M | re.I)
|
||||
_BLOCKED_RE = re.compile(
|
||||
r"^\s*" + re.escape(LABEL_PREFIX) + r"/blocked-by:\s*(.+)$", re.M)
|
||||
|
||||
|
||||
def classify_test(t: dict) -> dict:
|
||||
"""Decide EXECUTABLE vs SCAFFOLD for one discovered test, conservatively.
|
||||
|
||||
A tagged test is only worth counting if it can actually assert something. Rules, in
|
||||
order, chosen so ambiguity NEVER upgrades a test:
|
||||
|
||||
scaffold the body contains TODO markers (a placeholder), *even if* it
|
||||
is annotated executable — an annotation must never outrank the
|
||||
body. That mismatch is also reported as a hygiene defect.
|
||||
executable annotated `conformance.home.k3s/executable: "true"` and no TODOs.
|
||||
executable-untagged no TODOs and a real runnable body, but the annotation is
|
||||
missing (true of the L1 render tests, which execute a
|
||||
render-check.sh + golden diff, of the L0 static checks, and
|
||||
of the META self-check).
|
||||
Counted as executable, and flagged as an annotation gap.
|
||||
scaffold anything else.
|
||||
"""
|
||||
p = Path(t["path"])
|
||||
text = p.read_text() if p.is_file() else ""
|
||||
# The offline assertion script a test dir may carry: `render-check.sh` (L1 rendering)
|
||||
# or `check.sh` (L0 static). BOTH are read, so a placeholder body in either one still
|
||||
# downgrades the test to a scaffold — an annotation must never outrank the body,
|
||||
# whichever file the body lives in.
|
||||
extra = ""
|
||||
for script in ("render-check.sh", "check.sh"):
|
||||
sp = p.parent / script
|
||||
if sp.is_file():
|
||||
extra += sp.read_text()
|
||||
declared = bool(_EXECUTABLE_RE.search(text))
|
||||
has_todo = "TODO" in text or "TODO" in extra
|
||||
bm = _BLOCKED_RE.search(text)
|
||||
has_body = bool(extra) or bool(re.search(r"^\s+- (script|assert|apply):", text, re.M))
|
||||
|
||||
if has_todo:
|
||||
state = "scaffold"
|
||||
elif declared:
|
||||
state = "executable"
|
||||
elif has_body:
|
||||
state = "executable-untagged"
|
||||
else:
|
||||
state = "scaffold"
|
||||
|
||||
try:
|
||||
rel = os.path.relpath(p, REPO_ROOT)
|
||||
except ValueError:
|
||||
rel = t["path"]
|
||||
return {
|
||||
"name": t["name"], "path": rel, "layer": t["layer"],
|
||||
"state": state,
|
||||
"declared_executable": declared,
|
||||
"annotation_conflict": bool(declared and has_todo),
|
||||
"blocked_by": bm.group(1).strip().strip('"') if bm else "",
|
||||
}
|
||||
|
||||
|
||||
# ===================================================================== spec parity
|
||||
def spec_requirement_ids(spec_root: Path) -> set[str] | None:
|
||||
"""R-* IDs defined by the READ-ONLY spec (docs/00-vision-and-scope.md).
|
||||
|
||||
Read-only by construction (R-TEST-06): this tool never writes under the spec tree.
|
||||
Returns None when the spec tree is not available (a sibling checkout is optional).
|
||||
"""
|
||||
doc = spec_root / "docs" / "00-vision-and-scope.md"
|
||||
if not doc.is_file():
|
||||
return None
|
||||
return set(RID_RE.findall(doc.read_text()))
|
||||
|
||||
|
||||
# ========================================================================== build
|
||||
IMPL_ORDER = {"none": 0, "off-cluster": 1, "unwired": 2, "reconciled-partial": 3,
|
||||
"reconciled": 4}
|
||||
VERIFY_ORDER = {"none": 0, "scaffold-only": 1, "executable": 2}
|
||||
|
||||
LABELS = {
|
||||
("reconciled-partial", "executable"): "PARTLY-WIRED+TESTABLE",
|
||||
("reconciled-partial", "scaffold-only"): "PARTLY-WIRED, TEST-IS-SCAFFOLD",
|
||||
("reconciled-partial", "none"): "PARTLY-WIRED, UNTESTED",
|
||||
("reconciled", "executable"): "WIRED+TESTABLE",
|
||||
("reconciled", "scaffold-only"): "WIRED, TEST-IS-SCAFFOLD",
|
||||
("reconciled", "none"): "WIRED, UNTESTED",
|
||||
("unwired", "executable"): "AUTHORED-NOT-WIRED",
|
||||
("unwired", "scaffold-only"): "AUTHORED-NOT-WIRED",
|
||||
("unwired", "none"): "AUTHORED-NOT-WIRED",
|
||||
("off-cluster", "executable"): "TOOLING+TESTABLE",
|
||||
("off-cluster", "scaffold-only"): "TOOLING, TEST-IS-SCAFFOLD",
|
||||
("off-cluster", "none"): "TOOLING, UNTESTED",
|
||||
("none", "executable"): "TEST-ONLY",
|
||||
("none", "scaffold-only"): "SPEC-ONLY (scaffold test)",
|
||||
("none", "none"): "SPEC-ONLY",
|
||||
}
|
||||
|
||||
|
||||
def build(repo: Path, registry: list[dict], tests: list[dict], impl: dict[str, list[dict]],
|
||||
applied: set[str], gitops_roots: set[str], spec_ids: set[str] | None,
|
||||
env: str) -> dict:
|
||||
classified = [classify_test(t) for t in tests]
|
||||
by_req_tests: dict[str, list[dict]] = {r["id"]: [] for r in registry}
|
||||
for t, c in zip(tests, classified):
|
||||
for rid in t["labels"]:
|
||||
if rid in by_req_tests:
|
||||
by_req_tests[rid].append(c)
|
||||
|
||||
records: dict[str, dict] = {}
|
||||
for r in registry:
|
||||
rid = r["id"]
|
||||
files = impl.get(rid, [])
|
||||
ev = [{**f,
|
||||
"reconciled": f["path"] in applied,
|
||||
"gitops": (f["path"].split("/", 1)[0] in gitops_roots
|
||||
and f.get("manifest", False))}
|
||||
for f in files]
|
||||
gitops_ev = [e for e in ev if e["gitops"]]
|
||||
rec_ev = [e for e in gitops_ev if e["reconciled"]]
|
||||
unrec_ev = [e for e in gitops_ev if not e["reconciled"]]
|
||||
if not ev:
|
||||
impl_state = "none"
|
||||
elif rec_ev and unrec_ev:
|
||||
# Some citing manifests ARE applied and some are NOT. Never round this up to
|
||||
# "reconciled": e.g. R-UPD-01's only applied citation is a PrometheusRule that
|
||||
# merely mentions the ID, while platform/image-automation/ — the component
|
||||
# that actually implements it — is commented out of platform/kustomization.yaml.
|
||||
impl_state = "reconciled-partial"
|
||||
elif rec_ev:
|
||||
impl_state = "reconciled"
|
||||
elif gitops_ev:
|
||||
impl_state = "unwired"
|
||||
else:
|
||||
impl_state = "off-cluster"
|
||||
|
||||
ts = by_req_tests[rid]
|
||||
exec_ts = [t for t in ts if t["state"].startswith("executable")]
|
||||
if not ts:
|
||||
verify_state = "none"
|
||||
elif exec_ts:
|
||||
verify_state = "executable"
|
||||
else:
|
||||
verify_state = "scaffold-only"
|
||||
|
||||
safety_l3_ok = None
|
||||
if r["safety_critical"]:
|
||||
safety_l3_ok = any(t["layer"] == "L3" and t["state"].startswith("executable")
|
||||
for t in ts)
|
||||
|
||||
records[rid] = {
|
||||
"doc": r["doc"],
|
||||
"summary": r["summary"],
|
||||
"safety_critical": r["safety_critical"],
|
||||
"implementation": {
|
||||
"state": impl_state,
|
||||
"files_total": len(ev),
|
||||
"files_gitops": len(gitops_ev),
|
||||
"files_reconciled": len(rec_ev),
|
||||
"comment_only_evidence_only": bool(ev) and all(e["comment_only"] for e in ev),
|
||||
"files": ev,
|
||||
},
|
||||
"verification": {
|
||||
"state": verify_state,
|
||||
"executable_tests": len(exec_ts),
|
||||
"scaffold_tests": len(ts) - len(exec_ts),
|
||||
"layers": sorted({t["layer"] for t in exec_ts}),
|
||||
"safety_critical_l3_executable": safety_l3_ok,
|
||||
"blocked_by": sorted({t["blocked_by"] for t in ts if t["blocked_by"]}),
|
||||
"tests": ts,
|
||||
},
|
||||
# NEVER derived. See the module docstring.
|
||||
"proof": {"state": "not-derivable",
|
||||
"reason": "no committed, machine-readable record of a test run "
|
||||
"exists in the repo (_artifacts/ and results/ are "
|
||||
"git-ignored); DECISIONS-LOG.md prose is not a signal"},
|
||||
"status": LABELS[(impl_state, verify_state)],
|
||||
}
|
||||
|
||||
# --- hygiene defects this tool owns -------------------------------------------
|
||||
hygiene = {
|
||||
"annotation_conflicts": [t["path"] for t in classified if t["annotation_conflict"]],
|
||||
"executable_untagged": [t["path"] for t in classified
|
||||
if t["state"] == "executable-untagged"],
|
||||
"registry_not_in_spec": [],
|
||||
"spec_not_in_registry": [],
|
||||
"spec_parity_checked": spec_ids is not None,
|
||||
}
|
||||
if spec_ids is not None:
|
||||
reg_ids = {r["id"] for r in registry}
|
||||
hygiene["registry_not_in_spec"] = sorted(reg_ids - spec_ids)
|
||||
hygiene["spec_not_in_registry"] = sorted(spec_ids - reg_ids)
|
||||
|
||||
safety_scaffold = sorted(rid for rid, v in records.items()
|
||||
if v["safety_critical"]
|
||||
and v["verification"]["safety_critical_l3_executable"] is False)
|
||||
|
||||
def count(pred) -> int:
|
||||
return sum(1 for v in records.values() if pred(v))
|
||||
|
||||
summary = {
|
||||
"environment": env,
|
||||
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"total_requirements": len(registry),
|
||||
"impl_reconciled": count(lambda v: v["implementation"]["state"] == "reconciled"),
|
||||
"impl_reconciled_partial": count(
|
||||
lambda v: v["implementation"]["state"] == "reconciled-partial"),
|
||||
"impl_unwired": count(lambda v: v["implementation"]["state"] == "unwired"),
|
||||
"impl_off_cluster": count(lambda v: v["implementation"]["state"] == "off-cluster"),
|
||||
"impl_none": count(lambda v: v["implementation"]["state"] == "none"),
|
||||
"verify_executable": count(lambda v: v["verification"]["state"] == "executable"),
|
||||
"verify_scaffold_only": count(lambda v: v["verification"]["state"] == "scaffold-only"),
|
||||
"verify_none": count(lambda v: v["verification"]["state"] == "none"),
|
||||
"wired_and_testable": count(
|
||||
lambda v: v["implementation"]["state"] == "reconciled"
|
||||
and v["verification"]["state"] == "executable"),
|
||||
"highest_derivable_state_reached": count(
|
||||
lambda v: v["implementation"]["state"] in ("reconciled", "off-cluster")
|
||||
and v["verification"]["state"] == "executable"),
|
||||
"wired_or_partial_and_testable": count(
|
||||
lambda v: v["implementation"]["state"] in ("reconciled", "reconciled-partial")
|
||||
and v["verification"]["state"] == "executable"),
|
||||
"safety_critical_total": count(lambda v: v["safety_critical"]),
|
||||
"safety_critical_without_executable_l3": len(safety_scaffold),
|
||||
"proven": 0,
|
||||
"proven_derivable": False,
|
||||
"coverage_style_covered": count(lambda v: v["verification"]["state"] != "none"),
|
||||
}
|
||||
summary["uncited_by_any_reconciled_manifest"] = count(
|
||||
lambda v: v["implementation"]["state"] in ("none", "off-cluster"))
|
||||
return {"summary": summary, "by_requirement": records,
|
||||
"safety_critical_without_executable_l3": safety_scaffold,
|
||||
"hygiene": hygiene}
|
||||
|
||||
|
||||
def spec_rollup(rep: dict) -> dict:
|
||||
"""Per-spec-document rollup — 'the status of each spec', the operator's actual ask."""
|
||||
docs: dict[str, dict] = {}
|
||||
for rid, v in rep["by_requirement"].items():
|
||||
d = docs.setdefault(v["doc"] or "(unmapped)", {
|
||||
"requirements": [], "reconciled": 0, "partly_reconciled": 0,
|
||||
"executable": 0, "wired_and_testable": 0})
|
||||
d["requirements"].append(rid)
|
||||
if v["implementation"]["state"] == "reconciled":
|
||||
d["reconciled"] += 1
|
||||
elif v["implementation"]["state"] == "reconciled-partial":
|
||||
d["partly_reconciled"] += 1
|
||||
if v["verification"]["state"] == "executable":
|
||||
d["executable"] += 1
|
||||
if (v["implementation"]["state"] in ("reconciled", "off-cluster")
|
||||
and v["verification"]["state"] == "executable"):
|
||||
d["wired_and_testable"] += 1
|
||||
for d in docs.values():
|
||||
d["requirements"].sort()
|
||||
d["total"] = len(d["requirements"])
|
||||
return dict(sorted(docs.items()))
|
||||
|
||||
|
||||
# ========================================================================= render
|
||||
def render_md(rep: dict, nodes: list[dict], advisory: dict | None) -> str:
|
||||
s = rep["summary"]
|
||||
o: list[str] = []
|
||||
o.append("# Requirement status — derived from repository state\n")
|
||||
o.append(f"Environment overlay: `clusters/{s['environment']}` · "
|
||||
f"generated {s['generated_utc']} · **no cluster was contacted**.\n")
|
||||
o.append("\n## Headline\n")
|
||||
o.append(f"| Axis | Count (of {s['total_requirements']}) |")
|
||||
o.append("|---|---|")
|
||||
o.append(f"| Implementation fully reconciled by Flux | **{s['impl_reconciled']}** |")
|
||||
o.append(f"| Implementation only PARTLY reconciled (some citing manifests not "
|
||||
f"applied) | **{s['impl_reconciled_partial']}** |")
|
||||
o.append(f"| Implementation authored but NOT wired into the Flux graph | "
|
||||
f"**{s['impl_unwired']}** |")
|
||||
o.append(f"| Implementation is off-cluster tooling (CI / harness / IaC) | "
|
||||
f"{s['impl_off_cluster']} |")
|
||||
o.append(f"| No implementing artifact in the repo | **{s['impl_none']}** |")
|
||||
o.append(f"| Has an EXECUTABLE requirement-tagged test | **{s['verify_executable']}** |")
|
||||
o.append(f"| Tagged test exists but is a TODO SCAFFOLD | **{s['verify_scaffold_only']}** |")
|
||||
o.append(f"| No requirement-tagged test at all | **{s['verify_none']}** |")
|
||||
o.append(f"| Reconciled **and** executably tested (highest derivable state) | "
|
||||
f"**{s['highest_derivable_state_reached']}** |")
|
||||
o.append(f"| Safety-critical requirements whose L3 test is a scaffold | "
|
||||
f"**{s['safety_critical_without_executable_l3']}** of "
|
||||
f"{s['safety_critical_total']} |")
|
||||
o.append(f"| **PROVEN (a test actually passed)** | **{s['proven']} — NOT DERIVABLE** |")
|
||||
o.append("")
|
||||
o.append("> **Proof is deliberately not derived.** Nothing tracked in git records a "
|
||||
"test result: `conformance/_artifacts/` and `results/` are git-ignored, and "
|
||||
"the only record of a green run is DECISIONS-LOG.md prose — hand-maintained, "
|
||||
"which is the very thing this report replaces. Per "
|
||||
"`agents/definition-of-done.md` a requirement is done only when its tagged "
|
||||
"test **passes**, so the strongest claim this report can make is "
|
||||
"*wired and testable*. Closing the gap needs a committed, machine-readable "
|
||||
"run ledger; until then, treat every row below as unproven.\n")
|
||||
o.append(f"> For contrast, `coverage.md` counts **{s['coverage_style_covered']}** "
|
||||
f"requirements as covered — that count includes the "
|
||||
f"{s['verify_scaffold_only']} whose only verifying test is a TODO scaffold.\n")
|
||||
|
||||
o.append("\n## Status legend\n")
|
||||
o.append("| Label | Meaning |")
|
||||
o.append("|---|---|")
|
||||
o.append("| `WIRED+TESTABLE` | implementing config is reconciled by Flux **and** an "
|
||||
"executable tagged test exists (still unproven) |")
|
||||
o.append("| `WIRED, TEST-IS-SCAFFOLD` | reconciled, but its only tagged test is a "
|
||||
"TODO placeholder — coverage counts this green; it is not |")
|
||||
o.append("| `WIRED, UNTESTED` | reconciled with no tagged test at all |")
|
||||
o.append("| `PARTLY-WIRED…` | some citing manifests are applied, others are not — "
|
||||
"check whether the unapplied one is the real implementation |")
|
||||
o.append("| `AUTHORED-NOT-WIRED` | manifests exist under a GitOps root but no Flux "
|
||||
"Kustomization reaches them — in git, not on any cluster |")
|
||||
o.append("| `TOOLING…` | implemented by off-cluster tooling (ci/, conformance/, "
|
||||
"infra/, bootstrap/, schemas/) — nothing for Flux to reconcile |")
|
||||
o.append("| `TEST-ONLY` / `SPEC-ONLY` | a test but no implementation / neither |")
|
||||
|
||||
o.append("\n## Per-spec rollup\n")
|
||||
o.append("| Spec document | Reqs | Reconciled | Partly | Executable test | "
|
||||
"Wired+Testable |")
|
||||
o.append("|---|---|---|---|---|---|")
|
||||
for doc, d in spec_rollup(rep).items():
|
||||
o.append(f"| `{doc}` | {d['total']} | {d['reconciled']} | "
|
||||
f"{d['partly_reconciled']} | {d['executable']} | "
|
||||
f"**{d['wired_and_testable']}/{d['total']}** |")
|
||||
|
||||
o.append("\n## Status by requirement\n")
|
||||
o.append("| Requirement | Status | Implementation | Verification | Proof |")
|
||||
o.append("|---|---|---|---|---|")
|
||||
for rid, v in rep["by_requirement"].items():
|
||||
i, ver = v["implementation"], v["verification"]
|
||||
if i["state"] == "none":
|
||||
icell = "—"
|
||||
else:
|
||||
icell = (f"{i['state']} ({i['files_reconciled']}/{i['files_gitops']} gitops "
|
||||
f"files applied, {i['files_total']} refs)")
|
||||
if i["comment_only_evidence_only"]:
|
||||
icell += " *(comment-only evidence)*"
|
||||
if ver["state"] == "none":
|
||||
vcell = "—"
|
||||
else:
|
||||
layers = ",".join(ver["layers"]) or "-"
|
||||
vcell = (f"{ver['state']} [{layers}] "
|
||||
f"exec={ver['executable_tests']} scaffold={ver['scaffold_tests']}")
|
||||
if ver["blocked_by"]:
|
||||
vcell += f" · blocked-by: {'; '.join(ver['blocked_by'])}"
|
||||
sc = " ⚠safety" if v["safety_critical"] else ""
|
||||
o.append(f"| {rid}{sc} | **{v['status']}** | {icell} | {vcell} | not-derivable |")
|
||||
|
||||
if rep["safety_critical_without_executable_l3"]:
|
||||
o.append("\n## Safety-critical requirements whose L3 fault test is a SCAFFOLD "
|
||||
"(R-TEST-05)\n")
|
||||
o.append("`coverage.md` reports zero safety-critical gaps because a tagged L3 "
|
||||
"test file exists. These L3 tests have TODO bodies and assert nothing:\n")
|
||||
for rid in rep["safety_critical_without_executable_l3"]:
|
||||
v = rep["by_requirement"][rid]
|
||||
bb = "; ".join(v["verification"]["blocked_by"]) or "—"
|
||||
o.append(f"- **{rid}** — {v['summary']} · blocked-by: {bb}")
|
||||
|
||||
partial = [r for r, v in rep["by_requirement"].items()
|
||||
if v["implementation"]["state"] == "reconciled-partial"]
|
||||
if partial:
|
||||
o.append("\n## Partly wired — citing manifests that Flux does NOT apply\n")
|
||||
o.append("At least one manifest naming these requirements is reconciled, but "
|
||||
"others are not. Check whether the unapplied file is the one that "
|
||||
"actually implements the requirement.\n")
|
||||
for rid in partial:
|
||||
v = rep["by_requirement"][rid]
|
||||
miss = [f"`{f['path']}`" for f in v["implementation"]["files"]
|
||||
if f["gitops"] and not f["reconciled"]]
|
||||
o.append(f"- **{rid}** — not applied: {', '.join(miss)}")
|
||||
|
||||
unwired = [r for r, v in rep["by_requirement"].items()
|
||||
if v["implementation"]["state"] == "unwired"]
|
||||
if unwired:
|
||||
o.append("\n## Authored but NOT reconciled (in git, not on any cluster)\n")
|
||||
for rid in unwired:
|
||||
v = rep["by_requirement"][rid]
|
||||
fs = ", ".join(f"`{f['path']}`" for f in v["implementation"]["files"]
|
||||
if f["gitops"])[:400]
|
||||
o.append(f"- **{rid}** — {v['summary']}\n - {fs}")
|
||||
|
||||
uncited = [r for r, v in rep["by_requirement"].items()
|
||||
if v["implementation"]["state"] in ("none", "off-cluster")]
|
||||
if uncited:
|
||||
o.append("\n## No reconciled manifest cites these requirements (traceability, "
|
||||
"R-TEST-04)\n")
|
||||
o.append("Implementation evidence here is **a tracked, non-Markdown file that "
|
||||
"names the R-ID**. For these requirements no reconciled Kubernetes "
|
||||
"manifest names the ID, which means EITHER the requirement is genuinely "
|
||||
"unimplemented on the cluster, OR the implementing manifest exists but "
|
||||
"is missing its requirement annotation. This report cannot tell the two "
|
||||
"apart and deliberately does not guess — annotate the manifest and the "
|
||||
"row self-corrects.\n")
|
||||
for rid in uncited:
|
||||
v = rep["by_requirement"][rid]
|
||||
o.append(f"- **{rid}** ({v['implementation']['state']}) — {v['summary']}")
|
||||
|
||||
o.append("\n## Flux reconciliation graph walked\n")
|
||||
o.append("| Flux Kustomization | spec.path | present |")
|
||||
o.append("|---|---|---|")
|
||||
for n in sorted(nodes, key=lambda n: (n["resolved"], n["kustomization"])):
|
||||
o.append(f"| `{n['kustomization']}` | `{n['path']}` | "
|
||||
f"{'yes' if n['exists'] else '**MISSING**'} |")
|
||||
|
||||
h = rep["hygiene"]
|
||||
o.append("\n## Harness hygiene\n")
|
||||
if h["spec_parity_checked"]:
|
||||
drift = h["registry_not_in_spec"] + h["spec_not_in_registry"]
|
||||
o.append(f"- registry ↔ spec parity: {'**DRIFT**: ' + str(drift) if drift else 'in sync'}")
|
||||
else:
|
||||
o.append("- registry ↔ spec parity: **NOT CHECKED** (spec tree not available; "
|
||||
"this is a skip, not a pass)")
|
||||
o.append(f"- tests annotated executable but containing TODO bodies: "
|
||||
f"{h['annotation_conflicts'] or 'none'}")
|
||||
o.append(f"- executable tests missing the `{LABEL_PREFIX}/executable` annotation: "
|
||||
f"{h['executable_untagged'] or 'none'}")
|
||||
|
||||
o.append("\n## Method — what is derived, and what is refused\n")
|
||||
o.append("| Signal | How it is derived | Trust |")
|
||||
o.append("|---|---|---|")
|
||||
o.append("| requirement set | `conformance/requirements/registry.yaml`, "
|
||||
"cross-checked against the read-only spec `docs/00-vision-and-scope.md` | "
|
||||
"exact |")
|
||||
o.append("| implementing artifact | a TRACKED, non-Markdown file that names the R-ID "
|
||||
"(comments counted, and marked as such) | heuristic — under-reports "
|
||||
"un-annotated manifests, never over-reports |")
|
||||
o.append("| reconciled | the file is reachable by walking `resources:` from "
|
||||
"`clusters/<env>/kustomization.yaml`, expanding every Flux Kustomization's "
|
||||
"`spec.path` | exact — a commented-out `resources:` entry is correctly NOT "
|
||||
"reconciled |")
|
||||
o.append("| verifying test | `conformance.home.k3s/R-*` label on a Chainsaw test "
|
||||
"(the authoritative tag, same source as `coverage.md`) | exact |")
|
||||
o.append("| executable vs scaffold | TODO markers in the test body outrank the "
|
||||
"`executable` annotation; a real body without the annotation still counts "
|
||||
"and is flagged | conservative — ambiguity never upgrades |")
|
||||
o.append("| **test actually passed** | **NOT DERIVED** — nothing in git records a "
|
||||
"run result | refused |")
|
||||
o.append("| cluster liveness (pods/CRs healthy) | **NOT DERIVED** — no cluster is "
|
||||
"contacted; `reconciled` means Flux *would* apply it, not that it is "
|
||||
"healthy | refused |")
|
||||
o.append("| phase/board status | **NOT DERIVED** — `agents/task-board.md` is stale "
|
||||
"and is not a signal | refused |")
|
||||
|
||||
o.append("\n## Advisory — untracked local run artifact (NOT counted)\n")
|
||||
if advisory:
|
||||
o.append(f"- `{advisory['path']}` (untracked, git-ignored), last written "
|
||||
f"{advisory['mtime']}: {advisory['tests']} testcase(s), "
|
||||
f"{advisory['failures']} failure(s), {advisory['skipped']} skipped.")
|
||||
o.append(f"- requirement IDs it names: {', '.join(advisory['requirements']) or '—'}")
|
||||
o.append("- This is one developer's local workspace, not repo state. It is shown "
|
||||
"for orientation only and contributes to NO status above.")
|
||||
else:
|
||||
o.append("- none present.")
|
||||
return "\n".join(o) + "\n"
|
||||
|
||||
|
||||
def read_advisory(junit: Path) -> dict | None:
|
||||
"""Quarantined read of an untracked local JUnit. Never feeds a status."""
|
||||
if not junit.is_file():
|
||||
return None
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
root = ET.parse(junit).getroot()
|
||||
except ET.ParseError:
|
||||
return None
|
||||
cases = root.iter("testcase")
|
||||
n = f = sk = 0
|
||||
reqs: set[str] = set()
|
||||
for c in cases:
|
||||
n += 1
|
||||
if c.find("failure") is not None or c.find("error") is not None:
|
||||
f += 1
|
||||
if c.find("skipped") is not None:
|
||||
sk += 1
|
||||
reqs |= set(RID_RE.findall(c.get("classname") or ""))
|
||||
for p in c.iter("property"):
|
||||
if p.get("name") == "requirements":
|
||||
reqs |= set(RID_RE.findall(p.get("value") or ""))
|
||||
return {"path": os.path.relpath(junit, REPO_ROOT), "tests": n, "failures": f,
|
||||
"skipped": sk, "requirements": sorted(reqs),
|
||||
"mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime(junit.stat().st_mtime))}
|
||||
|
||||
|
||||
# ======================================================================= selftest
|
||||
def _selftest() -> int:
|
||||
"""Deterministic, no-cluster self-test of every derivation rule (house pattern:
|
||||
`catalog/render.py --selftest`). Uses a throwaway tree, never the real repo."""
|
||||
import tempfile
|
||||
ok = True
|
||||
|
||||
def check(label: str, cond: bool) -> None:
|
||||
nonlocal ok
|
||||
if not cond:
|
||||
sys.stderr.write(f"status.py selftest: FAIL — {label}\n")
|
||||
ok = False
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
# A kustomization whose second entry is COMMENTED OUT must not be reconciled.
|
||||
(root / "clusters" / "dev").mkdir(parents=True)
|
||||
(root / "platform" / "on").mkdir(parents=True)
|
||||
(root / "platform" / "off").mkdir(parents=True)
|
||||
(root / "clusters" / "dev" / "kustomization.yaml").write_text(
|
||||
"resources:\n - platform-ks.yaml # comment\n")
|
||||
(root / "clusters" / "dev" / "platform-ks.yaml").write_text(
|
||||
"apiVersion: kustomize.toolkit.fluxcd.io/v1\n"
|
||||
"kind: Kustomization\nmetadata:\n name: platform\nspec:\n"
|
||||
" path: ./platform\n")
|
||||
(root / "platform" / "kustomization.yaml").write_text(
|
||||
"resources:\n - on\n # - off # deliberately disabled\n")
|
||||
# flush-left items (the `render.py --emit-waves` style) must parse too.
|
||||
(root / "platform" / "on" / "kustomization.yaml").write_text(
|
||||
"resources:\n- r.yaml\npatches: []\n")
|
||||
(root / "platform" / "on" / "r.yaml").write_text("# serves R-NET-01\nkind: X\n")
|
||||
(root / "platform" / "off" / "kustomization.yaml").write_text(
|
||||
"resources:\n - r.yaml\n")
|
||||
(root / "platform" / "off" / "r.yaml").write_text("# serves R-UPD-01\nkind: X\n")
|
||||
|
||||
applied, nodes = reconciled_files(root, "clusters/dev")
|
||||
check("commented-out resource is NOT reconciled",
|
||||
"platform/on/r.yaml" in applied and "platform/off/r.yaml" not in applied)
|
||||
check("flux spec.path expands the graph",
|
||||
any(n["path"] == "./platform" for n in nodes))
|
||||
|
||||
files = ["platform/on/r.yaml", "platform/off/r.yaml", "docs/x.md",
|
||||
"conformance/tests/t/chainsaw-test.yaml"]
|
||||
(root / "docs").mkdir(); (root / "docs" / "x.md").write_text("R-NET-01 R-TLS-01\n")
|
||||
(root / "conformance" / "tests" / "t").mkdir(parents=True)
|
||||
(root / "conformance" / "tests" / "t" / "chainsaw-test.yaml").write_text("R-DNS-01\n")
|
||||
impl = scan_implementation(root, files)
|
||||
check("markdown is excluded from implementation evidence", "R-TLS-01" not in impl)
|
||||
check("conformance/tests is excluded from implementation evidence",
|
||||
"R-DNS-01" not in impl)
|
||||
check("comment-only evidence is recorded as such",
|
||||
impl["R-NET-01"][0]["comment_only"] is True)
|
||||
|
||||
(root / "platform" / "on" / "real.yaml").write_text(
|
||||
"kind: ClusterPolicy\n conformance.home.k3s/R-SEC-01: \"true\"\n")
|
||||
impl2 = scan_implementation(root, ["platform/on/real.yaml"])
|
||||
check("non-comment evidence is recorded as such",
|
||||
impl2["R-SEC-01"][0]["comment_only"] is False)
|
||||
check("a yaml with a top-level kind: is a reconcilable manifest",
|
||||
impl2["R-SEC-01"][0]["manifest"] is True)
|
||||
(root / "platform" / "on" / "s.sh").write_text("# R-SEC-02\n")
|
||||
check("a shell script is never a reconcilable manifest",
|
||||
scan_implementation(root, ["platform/on/s.sh"])["R-SEC-02"][0]["manifest"]
|
||||
is False)
|
||||
|
||||
# classify_test: TODO body outranks an `executable: true` annotation.
|
||||
td2 = root / "conformance" / "tests" / "s"
|
||||
td2.mkdir(parents=True)
|
||||
f = td2 / "chainsaw-test.yaml"
|
||||
f.write_text("metadata:\n annotations:\n"
|
||||
" conformance.home.k3s/executable: \"true\"\n"
|
||||
"spec:\n steps:\n - script:\n"
|
||||
" content: 'echo \"TODO: write me\"'\n")
|
||||
c = classify_test({"name": "s", "path": str(f), "layer": "L2"})
|
||||
check("TODO body downgrades a test annotated executable",
|
||||
c["state"] == "scaffold" and c["annotation_conflict"])
|
||||
f.write_text("metadata:\n annotations:\n"
|
||||
" conformance.home.k3s/executable: \"true\"\n"
|
||||
"spec:\n steps:\n - script:\n content: 'true'\n")
|
||||
check("annotated + no TODO == executable",
|
||||
classify_test({"name": "s", "path": str(f), "layer": "L2"})["state"]
|
||||
== "executable")
|
||||
f.write_text("spec:\n steps:\n - script:\n content: 'true'\n")
|
||||
check("real body without the annotation == executable-untagged",
|
||||
classify_test({"name": "s", "path": str(f), "layer": "L2"})["state"]
|
||||
== "executable-untagged")
|
||||
f.write_text("spec: {}\n")
|
||||
check("empty body == scaffold",
|
||||
classify_test({"name": "s", "path": str(f), "layer": "L2"})["state"]
|
||||
== "scaffold")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
sys.stderr.write("status.py selftest: PASS\n")
|
||||
return 0
|
||||
|
||||
|
||||
# =========================================================================== main
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--repo", type=Path, default=REPO_ROOT)
|
||||
ap.add_argument("--registry", type=Path,
|
||||
default=CONF_ROOT / "requirements" / "registry.yaml")
|
||||
ap.add_argument("--tests", type=Path, default=CONF_ROOT / "tests")
|
||||
ap.add_argument("--out", type=Path, default=CONF_ROOT / "_artifacts" / "status")
|
||||
ap.add_argument("--env", default="dev",
|
||||
help="cluster overlay to walk (clusters/<env>); default dev")
|
||||
ap.add_argument("--spec-root", type=Path, default=None,
|
||||
help=f"read-only spec checkout for the registry parity check "
|
||||
f"(default {DEFAULT_SPEC_ROOT} if present)")
|
||||
ap.add_argument("--strict", action="store_true",
|
||||
help="exit 1 on defects this tool owns: registry/spec drift, "
|
||||
"executable-annotation conflicts, or a safety-critical "
|
||||
"requirement whose only L3 test is a scaffold")
|
||||
ap.add_argument("--selftest", action="store_true", help="run the built-in self-test")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.selftest:
|
||||
return _selftest()
|
||||
|
||||
repo = args.repo.resolve()
|
||||
entry = f"clusters/{args.env}"
|
||||
if not (repo / entry / "kustomization.yaml").is_file():
|
||||
sys.stderr.write(f"[status] no Flux entrypoint at {entry}/kustomization.yaml — "
|
||||
f"cannot derive reconciliation. SKIPPED (not green).\n")
|
||||
return 3
|
||||
|
||||
explicit_spec = args.spec_root is not None
|
||||
spec_root = (args.spec_root or DEFAULT_SPEC_ROOT).resolve()
|
||||
spec_ids = spec_requirement_ids(spec_root)
|
||||
if spec_ids is None and explicit_spec:
|
||||
sys.stderr.write(f"[status] --spec-root {spec_root} has no "
|
||||
f"docs/00-vision-and-scope.md. SKIPPED (not green).\n")
|
||||
return 3
|
||||
|
||||
registry = cov.parse_registry(args.registry)
|
||||
if not registry:
|
||||
sys.stderr.write("[status] registry parsed to zero requirements — refusing to "
|
||||
"emit a report that would read as 100% of nothing.\n")
|
||||
return 1
|
||||
tests = cov.discover_tests(args.tests)
|
||||
applied, nodes = reconciled_files(repo, entry)
|
||||
gitops_roots = {n["resolved"].split("/", 1)[0] for n in nodes if n["exists"]}
|
||||
impl = scan_implementation(repo, tracked_files(repo))
|
||||
rep = build(repo, registry, tests, impl, applied, gitops_roots, spec_ids, args.env)
|
||||
rep["flux_graph"] = nodes
|
||||
rep["gitops_roots"] = sorted(gitops_roots)
|
||||
rep["reconciled_file_count"] = len(applied)
|
||||
|
||||
advisory = read_advisory(CONF_ROOT / "_artifacts" / "report.junit.xml")
|
||||
rep["advisory_untracked_junit"] = advisory
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
(args.out / "status.json").write_text(json.dumps(rep, indent=2) + "\n")
|
||||
(args.out / "status.md").write_text(render_md(rep, nodes, advisory))
|
||||
|
||||
s = rep["summary"]
|
||||
print(f"[status] {s['total_requirements']} requirements | reconciled "
|
||||
f"{s['impl_reconciled']} (+{s['impl_reconciled_partial']} partial) | "
|
||||
f"executable test {s['verify_executable']} | "
|
||||
f"wired+testable {s['highest_derivable_state_reached']} "
|
||||
f"(+{s['wired_or_partial_and_testable'] - s['wired_and_testable']} partial) | "
|
||||
f"scaffold-only {s['verify_scaffold_only']} | "
|
||||
f"safety-critical w/o executable L3 "
|
||||
f"{s['safety_critical_without_executable_l3']} | PROVEN: not derivable")
|
||||
print(f"[status] wrote {args.out/'status.md'} and {args.out/'status.json'}")
|
||||
|
||||
rc = 0
|
||||
if args.strict:
|
||||
h = rep["hygiene"]
|
||||
if h["registry_not_in_spec"] or h["spec_not_in_registry"]:
|
||||
sys.stderr.write(f"[status] STRICT: registry/spec drift "
|
||||
f"{h['registry_not_in_spec']} {h['spec_not_in_registry']}\n")
|
||||
rc = 1
|
||||
if h["annotation_conflicts"]:
|
||||
sys.stderr.write(f"[status] STRICT: tests annotated executable with TODO "
|
||||
f"bodies: {h['annotation_conflicts']}\n")
|
||||
rc = 1
|
||||
if rep["safety_critical_without_executable_l3"]:
|
||||
sys.stderr.write(f"[status] STRICT: safety-critical requirement(s) whose L3 "
|
||||
f"test is a scaffold: "
|
||||
f"{rep['safety_critical_without_executable_l3']}\n")
|
||||
rc = 1
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+84
-32
@@ -8,9 +8,14 @@
|
||||
#
|
||||
# ./run.sh # full suite; skips L1/L2/L3 if no cluster
|
||||
# ./run.sh --requirements R-NET-01,R-NET-06 # merge-gate subset (tagged)
|
||||
# ./run.sh --layer l2 # one layer
|
||||
# ./run.sh --layer l2 # one layer (l0|l1|l2|l3|meta|all; l0/l1 need no cluster)
|
||||
# ./run.sh --requirements R-NET-01,R-NET-02 --layer l2 # intersect: tagged AND in layer
|
||||
# ./run.sh --coverage-only # regenerate coverage report only (no cluster)
|
||||
# ./run.sh --status # DERIVED STATUS report only (no cluster): per
|
||||
# # requirement, is it implemented, is it actually
|
||||
# # reconciled by Flux, is its test executable or a
|
||||
# # scaffold. Proof-of-pass is deliberately NOT
|
||||
# # derived — see lib/status.py.
|
||||
# ./run.sh --lint # structural-validate test defs (no cluster)
|
||||
# ./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
||||
# ./run.sh --requirements R-NET-01 # ALSO asserts those IDs are covered (merge gate)
|
||||
@@ -20,6 +25,8 @@
|
||||
# _artifacts/report.junit.xml JUnit (Chainsaw output, or skipped-scaffold shim)
|
||||
# _artifacts/coverage/coverage.md human coverage report
|
||||
# _artifacts/coverage/coverage.json machine coverage report
|
||||
# _artifacts/status/status.md human requirement-status report (--status)
|
||||
# _artifacts/status/status.json machine requirement-status report (--status)
|
||||
#
|
||||
# Phase 0: no cluster exists. The behavior tests are valid scaffolds that cannot
|
||||
# execute yet, so run.sh emits a *skipped* JUnit and a coverage report, and exits
|
||||
@@ -36,12 +43,13 @@ source "${CONF_ROOT}/versions.env"
|
||||
|
||||
ARTIFACTS="${CONF_ROOT}/_artifacts"
|
||||
COVERAGE_OUT="${ARTIFACTS}/coverage"
|
||||
STATUS_OUT="${ARTIFACTS}/status"
|
||||
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
LAYER="all"
|
||||
REQS=""
|
||||
MODE="run" # run | coverage-only | lint
|
||||
MODE="run" # run | coverage-only | status | lint
|
||||
STRICT_COVERAGE=0
|
||||
REQUIRE_ALL=0
|
||||
FORCE_NO_CLUSTER=0
|
||||
@@ -84,6 +92,7 @@ while [ $# -gt 0 ]; do
|
||||
--layer) LAYER="$2"; shift 2 ;;
|
||||
--requirements) REQS="$(conf_normalize_reqs "$2")"; shift 2 ;;
|
||||
--coverage-only) MODE="coverage-only"; shift ;;
|
||||
--status) MODE="status"; shift ;;
|
||||
--lint) MODE="lint"; shift ;;
|
||||
--strict-coverage) STRICT_COVERAGE=1; shift ;;
|
||||
--require-all) REQUIRE_ALL=1; shift ;;
|
||||
@@ -93,7 +102,7 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "${ARTIFACTS}" "${COVERAGE_OUT}"
|
||||
mkdir -p "${ARTIFACTS}" "${COVERAGE_OUT}" "${STATUS_OUT}"
|
||||
|
||||
log() { printf '\033[1m[run.sh]\033[0m %s\n' "$*"; }
|
||||
|
||||
@@ -111,6 +120,23 @@ gen_coverage() {
|
||||
"${flags[@]}"
|
||||
}
|
||||
|
||||
# --- Derived STATUS report (no cluster) ------------------------------------------------
|
||||
# Coverage answers "does a tagged test EXIST?". Status answers the operator's question —
|
||||
# "what is the real state of each requirement given what is actually in the repo?" — by
|
||||
# deriving, per requirement: implementing artifact? reachable from a Flux Kustomization
|
||||
# (reconciled, not merely authored)? tagged test that is EXECUTABLE rather than a TODO
|
||||
# scaffold? It REFUSES to derive whether a test passed: nothing tracked in git records a
|
||||
# run result, and a status report that defaults to green is worse than none.
|
||||
gen_status() {
|
||||
"${PYTHON}" "${CONF_ROOT}/lib/status.py" \
|
||||
--repo "$(cd "${CONF_ROOT}/.." && pwd)" \
|
||||
--registry "${CONF_ROOT}/requirements/registry.yaml" \
|
||||
--tests "${CONF_ROOT}/tests" \
|
||||
--out "${STATUS_OUT}" \
|
||||
--env "${CONF_ENV:-dev}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# --- Structural lint (no cluster): parse each test def and check the tag convention ----
|
||||
lint_tests() {
|
||||
log "Linting test definitions (structural, no cluster)"
|
||||
@@ -254,26 +280,40 @@ run_chainsaw() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- L1 rendering: cluster-INDEPENDENT render checks (docs/14) --------------------------
|
||||
# L1 verifies ServiceDefinition -> rendered-resources against golden expectations; it needs
|
||||
# no live cluster. Each l1-rendering test dir carries a `render-check.sh` (renderer + golden
|
||||
# diff + structural asserts). We run those directly and emit a REAL pass/fail JUnit, instead
|
||||
# of skipping L1 with the cluster-gated layers (T-0.3). Honors the same --layer/--requirements
|
||||
# selection as the rest of the suite (select_dirs), filtered to the l1-rendering subtree.
|
||||
l1_selected_dirs() {
|
||||
select_dirs | grep '/l1-rendering/' || true
|
||||
# --- L0/L1: the cluster-INDEPENDENT layers (docs/14) ------------------------------------
|
||||
# docs/14 puts TWO layers in CI without a cluster:
|
||||
# L0 static/policy tests/l0-static/** each dir carries a `check.sh` (repo invariants)
|
||||
# L1 rendering tests/l1-rendering/** each dir carries a `render-check.sh` (render == golden)
|
||||
# Both are executed directly here and emit a REAL pass/fail JUnit, instead of being skipped
|
||||
# with the cluster-gated layers. Honors the same --layer/--requirements selection as the rest
|
||||
# of the suite (select_dirs), filtered to those two subtrees.
|
||||
OFFLINE_SUBTREES='/l0-static/|/l1-rendering/'
|
||||
|
||||
offline_selected_dirs() {
|
||||
select_dirs | grep -E "${OFFLINE_SUBTREES}" || true
|
||||
}
|
||||
|
||||
run_l1_offline() {
|
||||
local -a l1dirs
|
||||
mapfile -t l1dirs < <(l1_selected_dirs)
|
||||
if [ "${#l1dirs[@]}" -eq 0 ]; then
|
||||
log "No L1 render tests match the selection (layer=${LAYER} reqs='${REQS}')."
|
||||
# True when EVERY requested layer is cluster-independent, so the offline result is the
|
||||
# whole result and run.sh must exit on it (the ci/l0.sh + ci/l1.sh merge-gate paths).
|
||||
layers_are_offline_only() {
|
||||
local l
|
||||
[ "${LAYER}" = all ] && return 1
|
||||
for l in ${LAYER//,/ }; do
|
||||
case "$l" in l0|l1) ;; *) return 1 ;; esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
run_offline_checks() {
|
||||
local -a dirs
|
||||
mapfile -t dirs < <(offline_selected_dirs)
|
||||
if [ "${#dirs[@]}" -eq 0 ]; then
|
||||
log "No L0/L1 offline checks match the selection (layer=${LAYER} reqs='${REQS}')."
|
||||
return 0
|
||||
fi
|
||||
log "Running ${#l1dirs[@]} L1 render check(s) OFFLINE (no cluster; docs/14)."
|
||||
log "Running ${#dirs[@]} offline L0/L1 check(s) (no cluster; docs/14)."
|
||||
"${PYTHON}" "${CONF_ROOT}/lib/run_l1_offline.py" \
|
||||
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" "${l1dirs[@]}"
|
||||
--junit "${JUNIT_OUT}" --label-prefix "${CONF_LABEL_PREFIX}" "${dirs[@]}"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
@@ -287,6 +327,17 @@ case "${MODE}" in
|
||||
log "coverage-only: done. See ${COVERAGE_OUT}/coverage.md"
|
||||
exit 0
|
||||
;;
|
||||
status)
|
||||
# rc: 0 report written, 1 a derivation/strict failure, 3 a check could not run
|
||||
# (SKIPPED, never counted as green — ci/lib.sh convention). Propagated as-is.
|
||||
rc=0
|
||||
# --strict-coverage is the harness's hygiene switch; apply it to status too.
|
||||
status_flags=()
|
||||
[ "${STRICT_COVERAGE}" = 1 ] && status_flags+=(--strict)
|
||||
gen_status ${status_flags[@]+"${status_flags[@]}"} || rc=$?
|
||||
log "status: done. See ${STATUS_OUT}/status.md"
|
||||
exit "${rc}"
|
||||
;;
|
||||
lint)
|
||||
lint_tests
|
||||
log "lint: done."
|
||||
@@ -294,29 +345,30 @@ case "${MODE}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# L1 rendering is cluster-independent (docs/14): run the selected L1 render checks offline
|
||||
# and treat their result as authoritative REGARDLESS of cluster availability. A render-check
|
||||
# failure is a real failure (nothing cluster-gated about it), so it always blocks.
|
||||
L1_RC=0
|
||||
run_l1_offline || L1_RC=$?
|
||||
if [ "${LAYER}" = l1 ]; then
|
||||
# L1-only run (the ci/l1.sh merge-gate path): the offline render result IS the result.
|
||||
if [ "${L1_RC}" -eq 0 ]; then
|
||||
log "Done (L1 render). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"
|
||||
# L0 static + L1 rendering are cluster-independent (docs/14): run the selected offline
|
||||
# checks and treat their result as authoritative REGARDLESS of cluster availability. An
|
||||
# offline check failure is a real failure (nothing cluster-gated about it), so it always blocks.
|
||||
OFFLINE_RC=0
|
||||
run_offline_checks || OFFLINE_RC=$?
|
||||
if layers_are_offline_only; then
|
||||
# Offline-only run (the ci/l0.sh + ci/l1.sh merge-gate paths): the offline result IS the result.
|
||||
if [ "${OFFLINE_RC}" -eq 0 ]; then
|
||||
log "Done (offline ${LAYER}). JUnit: ${JUNIT_OUT} | Coverage: ${COVERAGE_OUT}/coverage.md"
|
||||
else
|
||||
log "L1 render checks FAILED (rc=${L1_RC}). JUnit: ${JUNIT_OUT}"
|
||||
log "Offline ${LAYER} checks FAILED (rc=${OFFLINE_RC}). JUnit: ${JUNIT_OUT}"
|
||||
fi
|
||||
exit "${L1_RC}"
|
||||
exit "${OFFLINE_RC}"
|
||||
fi
|
||||
if [ "${L1_RC}" -ne 0 ]; then
|
||||
log "L1 render checks FAILED (rc=${L1_RC}) — blocking (render failure is not cluster-gated)."
|
||||
exit "${L1_RC}"
|
||||
if [ "${OFFLINE_RC}" -ne 0 ]; then
|
||||
log "Offline L0/L1 checks FAILED (rc=${OFFLINE_RC}) — blocking (not cluster-gated)."
|
||||
exit "${OFFLINE_RC}"
|
||||
fi
|
||||
|
||||
# run mode: need a cluster for L2/L3. Without one (Phase 0), emit skipped JUnit for the
|
||||
# cluster-gated layers. (L1 already ran offline above and passed.)
|
||||
if [ "${FORCE_NO_CLUSTER}" = 1 ] || ! conf_cluster_reachable; then
|
||||
log "No reachable cluster — emitting SKIPPED JUnit scaffold for L2/L3 (Phase 0 behavior)."
|
||||
log "NOTE: the offline L0/L1 results above already ran for real; this shim covers L2/L3 only."
|
||||
log "Also running structural lint so scaffolds stay valid."
|
||||
lint_tests || { log "lint failed"; exit 1; }
|
||||
"${PYTHON}" "${CONF_ROOT}/lib/emit_skipped_junit.py" \
|
||||
|
||||
@@ -4,6 +4,7 @@ Every test is a **Chainsaw `Test`** in its own directory:
|
||||
|
||||
```
|
||||
tests/
|
||||
l0-static/ L0 — whole-repo invariants over the tracked tree (no cluster)
|
||||
l1-rendering/ L1 — ServiceDefinition -> rendered resources match golden
|
||||
l2-behavioral/ L2 — real runtime behavior (probes against a cluster)
|
||||
l3-fault-injection/ L3 — break something; assert fail-closed / no-data-loss
|
||||
@@ -14,6 +15,35 @@ tests/
|
||||
The layer a test belongs to is its subtree **and** its `.../layer` annotation; the
|
||||
coverage generator cross-checks the two.
|
||||
|
||||
### L0 static (`l0-static/`) — what belongs here, and what does not
|
||||
|
||||
docs/14's L0 row is *"Pre-apply, in CI · manifests/ServiceDefinitions obey invariants"*.
|
||||
Kyverno (`platform/kyverno/`) covers the invariants expressible over a **single
|
||||
resource**. This subtree covers the L0 invariants that are **properties of the repository
|
||||
as a whole** and that no per-resource policy can state — e.g. *no plaintext secret exists
|
||||
anywhere in the tracked tree*, *no tracked RoleBinding grants a workload identity a
|
||||
privileged role*, *the spec-immutability gate actually blocks a modified spec*.
|
||||
|
||||
Each `l0-static/<name>/` dir carries a **`check.sh`**: a self-contained offline assertion
|
||||
script (git + Python stdlib/PyYAML; no cluster, no network) that exits 0/1. It is executed:
|
||||
|
||||
- by `conformance/run.sh --layer l0` (and as the offline leg of a full run), via
|
||||
`lib/run_l1_offline.py`, producing a REAL pass/fail JUnit entry — never a skip; and
|
||||
- by the merge gate's **`ci/l0.sh`** stage, so these assertions run on every PR; and
|
||||
- by the wrapping Chainsaw `Test`, which runs the SAME `check.sh`, so an in-cluster run
|
||||
executes byte-identical assertions.
|
||||
|
||||
Two authoring rules specific to this layer:
|
||||
|
||||
1. **State the gap.** A statically-provable half of a requirement is still only half.
|
||||
Every `check.sh` header ends with an explicit *"what this does NOT prove"* list naming
|
||||
the cluster-layer remainder. Covering a requirement at L0 does not finish it.
|
||||
2. **Exemptions must be self-policing.** Where a check must tolerate something (a
|
||||
deliberately-plaintext policy fixture, an `.example` template), the exemption is
|
||||
conditioned on a property that would break if the exemption were abused — e.g. the
|
||||
plaintext Secret fixture is exempt *only while* a `kyverno-test.yaml` asserts the
|
||||
policy rejects it. Never a bare path allowlist.
|
||||
|
||||
## Requirement-tagging convention (authoritative)
|
||||
|
||||
A test declares the requirement(s) it verifies **three** ways; the label is the source
|
||||
@@ -28,7 +58,7 @@ metadata:
|
||||
annotations:
|
||||
# Human-readable canonical list — MUST equal the label set.
|
||||
conformance.home.k3s/requirements: "R-NET-01"
|
||||
# Declared layer (L1|L2|L3|META) — must match the subtree.
|
||||
# Declared layer (L0|L1|L2|L3|META) — must match the subtree.
|
||||
conformance.home.k3s/layer: "L2"
|
||||
# true => R-TEST-05 requires this to be (or have) an L3 fault-injection test.
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
@@ -65,4 +95,7 @@ and coverage extraction exact and robust.
|
||||
the PM — do not relax the assertion or the requirement.
|
||||
|
||||
Scaffolds in this tree carry `TODO(T-x.y)` markers where a fixture/assertion lands as a
|
||||
later roadmap phase delivers the capability under test.
|
||||
later roadmap phase delivers the capability under test. `lib/status.py` treats such a
|
||||
marker — in the `chainsaw-test.yaml`, in `render-check.sh`, or in `check.sh` — as
|
||||
outranking any `conformance.home.k3s/executable: "true"` annotation, so a placeholder body
|
||||
can never be reported as an executable test.
|
||||
@@ -0,0 +1,44 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — least privilege for workload identities and network reach (R-SEC-02)
|
||||
# Catalog: docs/14 L0 row, "Manifests/ServiceDefinitions obey invariants" — runs in
|
||||
# CI WITHOUT a cluster.
|
||||
#
|
||||
# GIVEN the repository as git tracks it,
|
||||
# WHEN every tracked RBAC object and every least-network-reach policy is inspected,
|
||||
# THEN no in-repo role grants a wildcard or a privilege-escalation verb, no workload
|
||||
# ServiceAccount is bound to a privileged role, the conformance runner's own
|
||||
# privileged test identity stays scoped to its authored ClusterRole, and each
|
||||
# least-network-reach policy both Enforces and is proven by an L0 negative row
|
||||
# (or a generated-resource row) to actually close reach.
|
||||
#
|
||||
# STATUS: EXECUTABLE — and DELIBERATELY PARTIAL. `check.sh` is a pure offline assertion
|
||||
# script (git ls-files + PyYAML); conformance/run.sh --layer l0 and ci/l0.sh run it
|
||||
# directly, and this Chainsaw Test wraps the SAME script. It proves the STATIC half of
|
||||
# both clauses. The RUNTIME half — that the API server actually denies an unrequested
|
||||
# verb, and that pod-to-pod traffic is actually dropped — needs a cluster and is NOT
|
||||
# asserted under this requirement; see the gap list in check.sh's header. R-SEC-02 is
|
||||
# therefore covered, not finished.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: least-privilege-rbac
|
||||
labels:
|
||||
conformance.home.k3s/R-SEC-02: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-SEC-02"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
Workload identities carry no wildcard, no escalation verb and no privileged
|
||||
binding; the least-network-reach policies are enforced and proven to reject.
|
||||
spec:
|
||||
steps:
|
||||
- name: scan-rbac-and-least-reach-policies
|
||||
description: >-
|
||||
Assert the authored least-privilege posture of every tracked RBAC object and
|
||||
the enforced, test-proven status of the least-network-reach policies.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — least-privilege-rbac
|
||||
# Serves: R-SEC-02 ("The platform MUST follow least-privilege for workload
|
||||
# identities and network reach." — spec docs/00-vision-and-scope.md;
|
||||
# detail in docs/07 "least privilege ... least network reach").
|
||||
#
|
||||
# The requirement has TWO halves and this check covers the STATIC half of each,
|
||||
# at the layer docs/14 puts them ("L0 Static/policy | Pre-apply, in CI").
|
||||
#
|
||||
# GIVEN the repository as git tracks it,
|
||||
# WHEN every tracked RBAC object and every least-network-reach policy is inspected,
|
||||
# THEN — workload identity —
|
||||
# (1) no in-repo Role/ClusterRole grants a wildcard apiGroup, resource or verb,
|
||||
# and none grants escalate/bind/impersonate (the privilege-escalation verbs);
|
||||
# (2) no (Cluster)RoleBinding binds a ServiceAccount — a WORKLOAD identity — to
|
||||
# `cluster-admin` or to any in-repo role holding wildcard rules. Human/Group
|
||||
# subjects are out of scope: R-SEC-02 constrains workload identities, and the
|
||||
# operator's own admin path is an OIDC group binding, deliberately;
|
||||
# (3) every ServiceAccount subject is namespaced (no cluster-wide ambiguity);
|
||||
# (4) the conformance runner's own SA — this workstream's PRIVILEGED TEST
|
||||
# IDENTITY, whose least-privilege authoring docs/14 explicitly delegates to
|
||||
# WS-TEST — is bound only to its own scoped ClusterRole, carries no wildcard
|
||||
# and no privilege-escalation verb, and grants nothing over RBAC objects
|
||||
# themselves (no rbac.authorization.k8s.io rules);
|
||||
# — network reach —
|
||||
# (5) the policies that implement least network reach exist, and each is Enforce
|
||||
# (or carries the Flux token that substitutes to Enforce): the default-deny
|
||||
# baseline generator, the baseline SHAPE validator, the host-namespace/
|
||||
# privilege restriction, and the egress-class label requirement;
|
||||
# (6) each of those policies is actually EXERCISED by the L0 Kyverno suite with a
|
||||
# NEGATIVE row (result: fail) — a policy nobody proves rejects anything is a
|
||||
# declaration, not a gate.
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gap, cluster layer — R-SEC-02 is NOT fully covered):
|
||||
# - RUNTIME least privilege of workload identities: that the API server actually
|
||||
# denies the conformance SA (or any workload SA) the verbs it was not granted.
|
||||
# That needs a live cluster (L2) and no test asserts it today.
|
||||
# - RUNTIME least network reach: that pod-to-pod traffic is actually denied. The
|
||||
# behaviour is exercised by tests/l2-behavioral/{default-deny,deny-undeclared},
|
||||
# but those are tagged R-NET-01/R-NET-02 and are cluster-gated (skipped in Phase 0);
|
||||
# nothing proves it under an R-SEC-02 tag.
|
||||
# - Helm-chart-generated RBAC (e.g. what a HelmRelease's chart creates in-cluster) is
|
||||
# not in the repo, so it is outside this static scan entirely.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
cd "${ROOT}"
|
||||
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||
import os, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def tracked() -> list[str]:
|
||||
out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return [p for p in out.split("\0") if p]
|
||||
|
||||
|
||||
def docs_of(rel: str):
|
||||
try:
|
||||
return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace"))
|
||||
if isinstance(d, dict)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
FILES = tracked()
|
||||
YAML_FILES = [f for f in FILES if re.search(r"\.(ya?ml)(\.example)?$", f)]
|
||||
check(len(YAML_FILES) > 50, f"only {len(YAML_FILES)} YAML files seen — not a real checkout")
|
||||
|
||||
ESCALATION_VERBS = {"escalate", "bind", "impersonate"}
|
||||
PRIVILEGED_ROLES = {"cluster-admin", "admin", "edit"}
|
||||
|
||||
roles: dict[tuple[str, str], dict] = {} # (kind, name) -> {"file":…, "wildcard":bool, …}
|
||||
bindings: list[dict] = []
|
||||
|
||||
for rel in YAML_FILES:
|
||||
for d in docs_of(rel):
|
||||
kind = d.get("kind")
|
||||
name = (d.get("metadata") or {}).get("name")
|
||||
if kind in ("Role", "ClusterRole"):
|
||||
wildcard, escalating, rbac_over_rbac = False, set(), False
|
||||
for r in d.get("rules") or []:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
for field in ("apiGroups", "resources", "verbs"):
|
||||
if "*" in (r.get(field) or []):
|
||||
wildcard = True
|
||||
escalating |= set(v for v in (r.get("verbs") or [])) & ESCALATION_VERBS
|
||||
if "rbac.authorization.k8s.io" in (r.get("apiGroups") or []):
|
||||
rbac_over_rbac = True
|
||||
roles[(kind, name)] = {"file": rel, "wildcard": wildcard,
|
||||
"escalating": escalating, "rbac": rbac_over_rbac,
|
||||
"doc": d}
|
||||
# (1) no wildcards, no privilege-escalation verbs.
|
||||
check(not wildcard,
|
||||
f"{rel}: {kind}/{name} grants a wildcard apiGroup/resource/verb — that is "
|
||||
"not least-privilege (R-SEC-02)")
|
||||
check(not escalating,
|
||||
f"{rel}: {kind}/{name} grants privilege-escalation verb(s) "
|
||||
f"{sorted(escalating)} (R-SEC-02)")
|
||||
elif kind in ("RoleBinding", "ClusterRoleBinding"):
|
||||
bindings.append({"file": rel, "name": name, "doc": d})
|
||||
|
||||
check(len(roles) > 0, "no Role/ClusterRole found in the repo — the RBAC scan is not looking at it")
|
||||
check(len(bindings) > 0, "no RoleBinding/ClusterRoleBinding found — the RBAC scan is not looking at it")
|
||||
|
||||
for b in bindings:
|
||||
d, rel, name = b["doc"], b["file"], b["name"]
|
||||
ref = d.get("roleRef") or {}
|
||||
ref_kind, ref_name = ref.get("kind"), ref.get("name")
|
||||
sa_subjects = [s for s in (d.get("subjects") or [])
|
||||
if isinstance(s, dict) and s.get("kind") == "ServiceAccount"]
|
||||
local = roles.get((ref_kind, ref_name))
|
||||
ref_is_privileged = (ref_name in PRIVILEGED_ROLES) or bool(local and local["wildcard"])
|
||||
# (2) a WORKLOAD identity must never hold a privileged role.
|
||||
check(not (sa_subjects and ref_is_privileged),
|
||||
f"{rel}: {d.get('kind')}/{name} binds ServiceAccount(s) "
|
||||
f"{[s.get('name') for s in sa_subjects]} to the privileged role {ref_name!r} — "
|
||||
"workload identities must be least-privilege (R-SEC-02)")
|
||||
# (3) SA subjects must name their namespace (a ClusterRoleBinding without one is
|
||||
# ambiguous and, in a RoleBinding, silently defaults to the binding's namespace).
|
||||
for s in sa_subjects:
|
||||
check(bool(s.get("namespace")) or d.get("kind") == "RoleBinding",
|
||||
f"{rel}: {d.get('kind')}/{name} has a ServiceAccount subject "
|
||||
f"{s.get('name')!r} with no namespace (R-SEC-02)")
|
||||
|
||||
# --------------------------------- (4) our own privileged test identity, in detail
|
||||
RBAC_FILE = "conformance/incluster/rbac.yaml"
|
||||
check(RBAC_FILE in FILES, f"{RBAC_FILE} is missing — the conformance runner has no authored RBAC")
|
||||
runner_role = roles.get(("ClusterRole", "conformance-runner"))
|
||||
check(runner_role is not None,
|
||||
f"{RBAC_FILE}: no ClusterRole/conformance-runner — the test identity's grants are undefined")
|
||||
if runner_role:
|
||||
check(runner_role["file"] == RBAC_FILE,
|
||||
f"ClusterRole/conformance-runner is defined in {runner_role['file']}, not {RBAC_FILE}")
|
||||
check(not runner_role["wildcard"], "ClusterRole/conformance-runner uses a wildcard (R-SEC-02)")
|
||||
check(not runner_role["escalating"],
|
||||
"ClusterRole/conformance-runner grants a privilege-escalation verb (R-SEC-02)")
|
||||
check(not runner_role["rbac"],
|
||||
"ClusterRole/conformance-runner grants rights over RBAC objects themselves — a test "
|
||||
"identity that can rewrite RBAC is not least-privilege (R-SEC-02)")
|
||||
|
||||
runner_bindings = [b for b in bindings
|
||||
if any(s.get("kind") == "ServiceAccount" and s.get("name") == "conformance-runner"
|
||||
for s in (b["doc"].get("subjects") or []))]
|
||||
check(len(runner_bindings) >= 1, "the conformance-runner ServiceAccount has no binding at all")
|
||||
for b in runner_bindings:
|
||||
ref = b["doc"].get("roleRef") or {}
|
||||
check(ref.get("name") == "conformance-runner",
|
||||
f"{b['file']}: conformance-runner is bound to {ref.get('name')!r} instead of its own "
|
||||
"scoped ClusterRole (R-SEC-02)")
|
||||
for s in b["doc"].get("subjects") or []:
|
||||
if s.get("kind") == "ServiceAccount":
|
||||
check(s.get("namespace") == "conformance",
|
||||
f"{b['file']}: conformance-runner subject is in namespace "
|
||||
f"{s.get('namespace')!r}, expected 'conformance' (R-SEC-02)")
|
||||
|
||||
# --------------------- (5)/(6) least NETWORK REACH: the policies exist, Enforce, and
|
||||
# are proven to reject something at L0.
|
||||
# Validating policies: must Enforce AND be proven to REJECT something at L0.
|
||||
REQUIRED_POLICIES = {
|
||||
"validate-default-deny-shape": "a baseline that leaves a direction open is rejected",
|
||||
"restrict-host-namespaces": "host namespaces / privileged containers are rejected",
|
||||
"require-egress-class-label": "workloads must declare their egress class",
|
||||
}
|
||||
# Generating policies have no validationFailureAction and no negative row: the proof
|
||||
# that they close network reach is that the L0 suite asserts the resource they GENERATE.
|
||||
REQUIRED_GENERATE_POLICIES = {
|
||||
"require-default-deny-baseline": "every namespace gets a default-deny baseline",
|
||||
}
|
||||
policies: dict[str, dict] = {}
|
||||
for rel in YAML_FILES:
|
||||
for d in docs_of(rel):
|
||||
if d.get("kind") in ("ClusterPolicy", "Policy"):
|
||||
policies[(d.get("metadata") or {}).get("name")] = {"file": rel, "doc": d}
|
||||
|
||||
for pname, why in REQUIRED_POLICIES.items():
|
||||
p = policies.get(pname)
|
||||
check(p is not None, f"policy {pname!r} is missing — nothing enforces: {why} (R-SEC-02)")
|
||||
if not p:
|
||||
continue
|
||||
action = str((p["doc"].get("spec") or {}).get("validationFailureAction", ""))
|
||||
check(action == "Enforce" or action.startswith("${"),
|
||||
f"{p['file']}: policy {pname!r} validationFailureAction is {action!r} — a least-reach "
|
||||
"policy in Audit mode blocks nothing (R-SEC-02)")
|
||||
|
||||
negatives: dict[str, int] = {}
|
||||
suites = [f for f in FILES if os.path.basename(f) == "kyverno-test.yaml"]
|
||||
check(len(suites) > 0, "no kyverno-test.yaml — the L0 policy gate is not exercised at all")
|
||||
for suite in suites:
|
||||
for d in docs_of(suite):
|
||||
for r in d.get("results") or []:
|
||||
if isinstance(r, dict) and r.get("result") == "fail":
|
||||
negatives[r.get("policy")] = negatives.get(r.get("policy"), 0) + 1
|
||||
|
||||
for pname, why in REQUIRED_POLICIES.items():
|
||||
check(negatives.get(pname, 0) > 0,
|
||||
f"no L0 test row asserts that policy {pname!r} REJECTS a violating resource "
|
||||
f"(result: fail) — unproven gate for: {why} (R-SEC-02)")
|
||||
|
||||
# The generating half: the policy must exist, and the L0 suite must assert the resource
|
||||
# it generates (a `generatedResource` row that passes) — otherwise nothing proves a new
|
||||
# namespace actually arrives with its reach closed.
|
||||
for pname, why in REQUIRED_GENERATE_POLICIES.items():
|
||||
p = policies.get(pname)
|
||||
check(p is not None, f"policy {pname!r} is missing — nothing enforces: {why} (R-SEC-02)")
|
||||
generated_rows = []
|
||||
for suite in suites:
|
||||
for d in docs_of(suite):
|
||||
for r in d.get("results") or []:
|
||||
if isinstance(r, dict) and r.get("policy") == pname \
|
||||
and r.get("generatedResource") and r.get("result") == "pass":
|
||||
generated_rows.append((suite, r.get("generatedResource")))
|
||||
check(len(generated_rows) > 0,
|
||||
f"no L0 test row asserts the resource generated by {pname!r} — unproven gate for: "
|
||||
f"{why} (R-SEC-02)")
|
||||
for suite, gen in generated_rows:
|
||||
gp = os.path.normpath(os.path.join(os.path.dirname(suite), str(gen)))
|
||||
check(gp in FILES, f"{suite}: generatedResource {gen!r} does not resolve to a tracked file")
|
||||
golden = "\n".join(l for l in (ROOT / gp).read_text(errors="replace").splitlines()
|
||||
if not l.lstrip().startswith("#")) if gp in FILES else ""
|
||||
check("NetworkPolicy" in golden and re.search(r"policyTypes:", golden),
|
||||
f"{gp}: the asserted default-deny baseline is not a shaped NetworkPolicy (R-SEC-02)")
|
||||
|
||||
# The host-namespace policy must prove BOTH of its rules reject, not just one.
|
||||
host_rules = set()
|
||||
for suite in suites:
|
||||
for d in docs_of(suite):
|
||||
for r in d.get("results") or []:
|
||||
if isinstance(r, dict) and r.get("policy") == "restrict-host-namespaces" \
|
||||
and r.get("result") == "fail":
|
||||
host_rules.add(r.get("rule"))
|
||||
check({"disallow-host-namespaces", "disallow-privileged-containers"} <= host_rules,
|
||||
"the L0 suite does not prove BOTH restrict-host-namespaces rules reject a violating pod "
|
||||
f"(negative rows seen for: {sorted(host_rules)}) — R-SEC-02")
|
||||
|
||||
# --------------------------------------------------------------------------- verdict
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL (least-privilege-rbac): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||
file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"check OK: least-privilege-rbac — {CHECKS} assertions over {len(roles)} role(s), "
|
||||
f"{len(bindings)} binding(s), {len(REQUIRED_POLICIES)} least-reach policies")
|
||||
PY
|
||||
@@ -0,0 +1,43 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — no plaintext secrets in git (R-SEC-01)
|
||||
# Catalog: docs/14 L0 row, "Manifests/ServiceDefinitions obey invariants ...
|
||||
# no plaintext secrets" — runs in CI WITHOUT a cluster.
|
||||
#
|
||||
# GIVEN the repository as git tracks it,
|
||||
# WHEN every tracked file is inspected,
|
||||
# THEN every committed Secret is SOPS ciphertext, every ansible group_vars vault is
|
||||
# ansible-vault encrypted, no private key material is tracked, decryption is
|
||||
# delegated to an in-cluster SOPS key, and the L0 policy that rejects plaintext
|
||||
# Secrets exists, Enforces, and is proven by a negative test row to reject one.
|
||||
#
|
||||
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git ls-files +
|
||||
# PyYAML, no cluster, no network); conformance/run.sh --layer l0 and ci/l0.sh run it
|
||||
# directly, and this Chainsaw Test wraps the SAME script so an in-cluster run executes
|
||||
# the identical assertions. Encryption at rest is necessary but NOT sufficient: the
|
||||
# runtime half of "decrypted only in-cluster", and a scan of git HISTORY, are named as
|
||||
# explicit gaps in check.sh's header and are NOT claimed here.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: no-plaintext-secrets
|
||||
labels:
|
||||
conformance.home.k3s/R-SEC-01: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-SEC-01"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
No secret is stored in plaintext in git: committed Secrets are SOPS ciphertext,
|
||||
ansible vaults are encrypted, no private key material is tracked, and the
|
||||
no-plaintext-secrets policy gate is enforced and proven to reject.
|
||||
spec:
|
||||
steps:
|
||||
- name: scan-tracked-tree-for-plaintext-secrets
|
||||
description: >-
|
||||
Assert over the whole tracked tree that no secret material is stored in
|
||||
plaintext and that decryption is delegated to the in-cluster SOPS key.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — no-plaintext-secrets
|
||||
# Serves: R-SEC-01 ("Secrets MUST NOT be stored in plaintext in git; they MUST be
|
||||
# encrypted at rest in the repo and decrypted only in-cluster."
|
||||
# — spec docs/00-vision-and-scope.md; detail in docs/07.)
|
||||
#
|
||||
# docs/14 puts exactly this invariant at L0 ("Static/policy | Pre-apply, in CI |
|
||||
# ... no plaintext secrets"), so it is asserted here over the WHOLE tracked tree,
|
||||
# with no cluster. Run by `conformance/run.sh --layer l0` and by ci/l0.sh.
|
||||
#
|
||||
# GIVEN the repository as git tracks it,
|
||||
# WHEN every tracked file is inspected,
|
||||
# THEN (1) SOPS is configured at the repo root with a PUBLIC age recipient;
|
||||
# (2) every committed Kubernetes Secret is SOPS-encrypted — a top-level
|
||||
# `sops:` block with a mac AND every data/stringData value is ENC[...];
|
||||
# (3) the only two exempt classes are narrow and self-policing:
|
||||
# a. `*.example` templates, which must NOT be referenced by any
|
||||
# kustomization and whose secret-named values must be placeholders;
|
||||
# b. the Kyverno L0 negative fixture, which is exempt ONLY while it is
|
||||
# actually wired into a kyverno-test.yaml that asserts
|
||||
# `disallow-plaintext-secrets` REJECTS it (result: fail);
|
||||
# (4) every ansible-vault group_vars file is `$ANSIBLE_VAULT;`-encrypted, and
|
||||
# no secret-valued ansible key carries a literal outside a vault;
|
||||
# (5) no private key material of any kind is tracked (PEM private keys, an
|
||||
# age SECRET key, kubeconfig client-key-data);
|
||||
# (6) decryption is delegated to the cluster: a Flux Kustomization declares
|
||||
# `decryption.provider: sops` with a secretRef, and the private half of
|
||||
# that key pair is nowhere in the repo (5).
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gap, cluster layer):
|
||||
# - that the decrypted Secret only ever materialises in-cluster (the runtime half
|
||||
# of "decrypted only in-cluster") — needs a live cluster (L2), untested today.
|
||||
# - that no plaintext secret exists in git HISTORY. This walks the WORKING TREE at
|
||||
# HEAD only; docs/07's history-scan acceptance gate needs a secret scanner over
|
||||
# all revisions and is NOT implemented here.
|
||||
# - value-level entropy: a placeholder-looking string that is in fact a real
|
||||
# credential is not detectable statically.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
cd "${ROOT}"
|
||||
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||
import os, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def tracked() -> list[str]:
|
||||
out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return [p for p in out.split("\0") if p]
|
||||
|
||||
|
||||
FILES = tracked()
|
||||
check(len(FILES) > 50, f"git ls-files returned only {len(FILES)} paths — not a real checkout")
|
||||
|
||||
|
||||
def docs_of(rel: str):
|
||||
"""Parsed YAML documents of a tracked file, or [] when it is not parseable YAML."""
|
||||
p = ROOT / rel
|
||||
try:
|
||||
return [d for d in yaml.safe_load_all(p.read_text(errors="replace")) if isinstance(d, dict)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
YAMLISH = re.compile(r"\.(ya?ml)(\.example)?$")
|
||||
YAML_FILES = [f for f in FILES if YAMLISH.search(f)]
|
||||
|
||||
# ---------------------------------------------------------------- (1) SOPS config
|
||||
sops_cfg = ROOT / ".sops.yaml"
|
||||
check(sops_cfg.is_file(), ".sops.yaml is missing from the repository root (R-SEC-01)")
|
||||
if sops_cfg.is_file():
|
||||
cfg = yaml.safe_load(sops_cfg.read_text()) or {}
|
||||
rules = cfg.get("creation_rules") or []
|
||||
check(len(rules) > 0, ".sops.yaml declares no creation_rules — new secrets would be committed unencrypted")
|
||||
for r in rules:
|
||||
pr = r.get("path_regex", "<none>")
|
||||
recips = str(r.get("age", ""))
|
||||
check(bool(recips), f".sops.yaml creation_rule {pr!r} has no age recipient")
|
||||
check(all(x.startswith("age1") for x in re.split(r"[,\s]+", recips) if x),
|
||||
f".sops.yaml creation_rule {pr!r} recipient is not a PUBLIC age1... key")
|
||||
check("AGE-SECRET-KEY" not in recips.upper(),
|
||||
f".sops.yaml creation_rule {pr!r} contains an age PRIVATE key")
|
||||
|
||||
# --------------------------------------------- Kyverno negative-fixture exemption
|
||||
# A plaintext Secret is tolerated ONLY where it is the negative case that proves the
|
||||
# L0 policy rejects plaintext. We do not trust the path: the file must be listed in a
|
||||
# kyverno-test.yaml whose results assert disallow-plaintext-secrets returns `fail`.
|
||||
POLICY_NAME = "disallow-plaintext-secrets"
|
||||
negative_fixtures: set[str] = set()
|
||||
kyverno_suites = [f for f in FILES if os.path.basename(f) == "kyverno-test.yaml"]
|
||||
check(len(kyverno_suites) > 0, "no kyverno-test.yaml found — the L0 policy gate is not exercised at all")
|
||||
for suite in kyverno_suites:
|
||||
docs = docs_of(suite)
|
||||
if not docs:
|
||||
continue
|
||||
d = docs[0]
|
||||
results = d.get("results") or []
|
||||
rejects_plaintext = any(r.get("policy") == POLICY_NAME and r.get("result") == "fail"
|
||||
for r in results if isinstance(r, dict))
|
||||
if not rejects_plaintext:
|
||||
continue
|
||||
base = os.path.dirname(suite)
|
||||
for res in d.get("resources") or []:
|
||||
negative_fixtures.add(os.path.normpath(os.path.join(base, str(res))))
|
||||
|
||||
check(len(negative_fixtures) > 0,
|
||||
f"no kyverno-test.yaml asserts that {POLICY_NAME} REJECTS a plaintext Secret "
|
||||
"(result: fail) — the plaintext-secret gate is unproven, so nothing may be exempt")
|
||||
|
||||
# --------------------------------------------------- (2)/(3) committed Secret scan
|
||||
SECRET_KEY_RE = re.compile(r"(pass|secret|token|key|credential|cookie|cert)", re.I)
|
||||
PLACEHOLDER_RE = re.compile(r"^\s*(|<[^>]*>|replace[-_ ]?me.*|change[-_ ]?me.*|placeholder.*|x{3,})\s*$", re.I)
|
||||
|
||||
secrets_seen = 0
|
||||
for rel in YAML_FILES:
|
||||
for d in docs_of(rel):
|
||||
if d.get("kind") != "Secret":
|
||||
continue
|
||||
secrets_seen += 1
|
||||
values = []
|
||||
for field in ("data", "stringData"):
|
||||
for k, v in (d.get(field) or {}).items():
|
||||
values.append((k, "" if v is None else str(v)))
|
||||
|
||||
is_example = rel.endswith(".example")
|
||||
is_negative_fixture = os.path.normpath(rel) in negative_fixtures
|
||||
|
||||
if is_example:
|
||||
# Template: never applied, and its secret-named values must be placeholders.
|
||||
refs = [k for k in YAML_FILES
|
||||
if os.path.basename(k) == "kustomization.yaml"
|
||||
and os.path.basename(rel) in (ROOT / k).read_text(errors="replace")]
|
||||
check(not refs,
|
||||
f"{rel}: an unencrypted .example template is referenced by a kustomization "
|
||||
f"({refs}) — it would be applied as a plaintext Secret")
|
||||
for k, v in values:
|
||||
if SECRET_KEY_RE.search(k):
|
||||
check(bool(PLACEHOLDER_RE.match(v)),
|
||||
f"{rel}: template key {k!r} holds {v[:24]!r}, not a placeholder — "
|
||||
"a real credential may have been committed (R-SEC-01)")
|
||||
continue
|
||||
|
||||
if is_negative_fixture:
|
||||
# Exempt, but only as the proven negative case; assert it really is plaintext
|
||||
# (an encrypted 'bad' fixture would silently stop testing anything).
|
||||
continue
|
||||
|
||||
# Everything else: must be SOPS-encrypted at rest.
|
||||
sops_block = d.get("sops")
|
||||
check(isinstance(sops_block, dict) and bool(sops_block.get("mac")),
|
||||
f"{rel}: committed Secret {d.get('metadata', {}).get('name')!r} has no top-level "
|
||||
"`sops:` block with a mac — it is stored in plaintext (R-SEC-01)")
|
||||
for k, v in values:
|
||||
check(v.startswith("ENC["),
|
||||
f"{rel}: Secret value {k!r} is not SOPS ciphertext (expected ENC[...]) — "
|
||||
"plaintext secret material in git (R-SEC-01)")
|
||||
|
||||
check(secrets_seen > 0, "no Kubernetes Secret manifests found at all — the scan is not looking at the repo")
|
||||
|
||||
# ------------------------------------------------------------ (4) ansible vaults
|
||||
VAULT_HEADER = "$ANSIBLE_VAULT;"
|
||||
vault_files = [f for f in FILES
|
||||
if os.path.basename(f) in ("vault.yml", "vault.yaml")]
|
||||
check(len(vault_files) > 0, "no ansible vault.yml files found — the vault scan is not looking at the repo")
|
||||
for rel in vault_files:
|
||||
head = (ROOT / rel).read_text(errors="replace")[:len(VAULT_HEADER)]
|
||||
check(head == VAULT_HEADER,
|
||||
f"{rel}: an ansible group_vars vault file is NOT ansible-vault encrypted "
|
||||
"(must start with the $ANSIBLE_VAULT header) — R-SEC-01")
|
||||
|
||||
SECRET_ANSIBLE_KEYS = ("ansible_become_password", "ansible_become_pass", "ansible_password",
|
||||
"ansible_ssh_pass", "ansible_sudo_pass", "ansible_ssh_private_key_file")
|
||||
ASSIGN_RE = re.compile(
|
||||
r"^\s*(" + "|".join(SECRET_ANSIBLE_KEYS) + r")\s*:\s*(.*?)\s*$")
|
||||
for rel in FILES:
|
||||
p = ROOT / rel
|
||||
if not p.is_file() or p.is_symlink():
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
if text.startswith(VAULT_HEADER):
|
||||
continue # encrypted at rest: opaque, by design
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
m = ASSIGN_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
key, val = m.group(1), m.group(2).strip().strip("'\"")
|
||||
ok = (val.startswith("{{") or val.startswith("$")
|
||||
or bool(PLACEHOLDER_RE.match(val)) or val == "")
|
||||
check(ok, f"{rel}:{lineno}: {key} is assigned the literal {val[:24]!r} outside an "
|
||||
"ansible-vault file — a plaintext secret in git (R-SEC-01)")
|
||||
|
||||
# ------------------------------------------------------ (5) no private key material
|
||||
# NOTE: each marker is assembled from fragments so that THIS file never contains the
|
||||
# literal string it hunts for — otherwise the scanner would flag itself the moment it
|
||||
# is tracked, and the only way to silence it would be to exempt the scanner (which is
|
||||
# exactly the kind of self-exemption this suite exists to prevent).
|
||||
_DASH5 = "-" * 5
|
||||
PRIVKEY_PATTERNS = [
|
||||
(re.compile(_DASH5 + r"BEGIN (?:[A-Z ]+ )?PRIVATE" + r" KEY" + _DASH5),
|
||||
"a PEM private key block"),
|
||||
(re.compile("AGE" + "-SECRET" + "-KEY-1"), "an age SECRET (decryption) key"),
|
||||
(re.compile(r"^\s*client" + r"-key-data\s*:\s*\S"), "kubeconfig client-key-data"),
|
||||
]
|
||||
for rel in FILES:
|
||||
p = ROOT / rel
|
||||
if not p.is_file() or p.is_symlink():
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
if text.startswith(VAULT_HEADER):
|
||||
continue
|
||||
for rx, what in PRIVKEY_PATTERNS:
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
if rx.search(line):
|
||||
check(False, f"{rel}:{lineno}: tracked file contains {what} (R-SEC-01)")
|
||||
|
||||
# ----------------------------------------- (6) decryption is delegated to the cluster
|
||||
sops_decryption = []
|
||||
for rel in YAML_FILES:
|
||||
for d in docs_of(rel):
|
||||
if d.get("kind") != "Kustomization":
|
||||
continue
|
||||
dec = d.get("spec", {}).get("decryption") if isinstance(d.get("spec"), dict) else None
|
||||
if isinstance(dec, dict) and dec.get("provider") == "sops" and dec.get("secretRef"):
|
||||
sops_decryption.append(rel)
|
||||
# Flux overlay patches carry the same block inside a patch body.
|
||||
for rel in YAML_FILES:
|
||||
text = (ROOT / rel).read_text(errors="replace")
|
||||
if re.search(r"^\s*decryption:\s*$", text, re.M) and re.search(r"^\s*provider:\s*sops\s*$", text, re.M) \
|
||||
and re.search(r"^\s*secretRef:\s*$", text, re.M):
|
||||
sops_decryption.append(rel)
|
||||
check(len(sops_decryption) > 0,
|
||||
"no Flux Kustomization declares `decryption: {provider: sops, secretRef: ...}` — nothing "
|
||||
"proves decryption happens in-cluster rather than from committed key material (R-SEC-01)")
|
||||
|
||||
# ------------------------------------------- (7) the L0 policy gate is real and strict
|
||||
policy_files = [f for f in FILES if f.endswith(f"{POLICY_NAME}.yaml")]
|
||||
check(len(policy_files) == 1, f"expected exactly one {POLICY_NAME} ClusterPolicy, found {policy_files}")
|
||||
for rel in policy_files:
|
||||
d = (docs_of(rel) or [{}])[0]
|
||||
action = str(d.get("spec", {}).get("validationFailureAction", ""))
|
||||
check(action == "Enforce" or action.startswith("${"),
|
||||
f"{rel}: validationFailureAction is {action!r} — the no-plaintext-secrets gate must "
|
||||
"Enforce (or carry the Flux token that substitutes to Enforce)")
|
||||
|
||||
# --------------------------------------------------------------------------- verdict
|
||||
if FAILURES:
|
||||
print(f"CHECK FAIL (no-plaintext-secrets): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||
file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"check OK: no-plaintext-secrets — {CHECKS} assertions over {len(FILES)} tracked files, "
|
||||
f"{secrets_seen} committed Secret document(s), {len(vault_files)} ansible vault file(s)")
|
||||
PY
|
||||
@@ -0,0 +1,46 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the specification is immutable to agents (R-TEST-06)
|
||||
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||
#
|
||||
# A governance rule is made testable by testing its ENFORCEMENT MECHANISM, not by
|
||||
# observing that no agent misbehaved today.
|
||||
#
|
||||
# GIVEN the enforcement mechanism (ci/spec-immutability.sh + ci/SPEC_PIN),
|
||||
# WHEN it is driven against hermetic THROWAWAY spec repositories in each state,
|
||||
# THEN a clean spec at the pinned SHA passes; an uncommitted edit under docs/ or
|
||||
# decisions/, an untracked spec addition, and an unadopted committed drift each
|
||||
# exit 1 and block the merge; an absent spec repo exits 3 (honest skip, never
|
||||
# green); an unpinned SPEC_PIN fails closed; and the merge gate runs the stage
|
||||
# and blocks on its failure.
|
||||
#
|
||||
# THE REAL SPEC REPO IS NEVER TOUCHED: every case runs against a git repo created under
|
||||
# mktemp with SPEC_REPO pointed at it and a copy of ci/ carrying a fixture SPEC_PIN.
|
||||
#
|
||||
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script; conformance/run.sh
|
||||
# --layer l0 and ci/l0.sh run it directly, and this Chainsaw Test wraps the SAME script.
|
||||
# NOT proven (named in check.sh's header): that the Gitea server REQUIRES this status
|
||||
# check before merge — branch protection is operator configuration outside the repo.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: spec-immutability-enforced
|
||||
labels:
|
||||
conformance.home.k3s/R-TEST-06: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-TEST-06"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
A spec modification is detected and blocks the merge; an unverifiable spec skips
|
||||
honestly rather than passing; and the gate wires that stage as blocking.
|
||||
spec:
|
||||
steps:
|
||||
- name: drive-the-spec-immutability-gate
|
||||
description: >-
|
||||
Run the real ci/spec-immutability.sh against throwaway spec repos in every
|
||||
state and assert its exit code, then assert the gate wires it as blocking.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 static check (offline, no cluster) — spec-immutability-enforced
|
||||
# Serves: R-TEST-06 ("AI agents MUST NOT modify the specifications (this docs/ tree,
|
||||
# the requirements, or accepted ADRs). Specs are changed only by the human
|
||||
# operator/architect." — spec docs/00-vision-and-scope.md.)
|
||||
#
|
||||
# HOW A GOVERNANCE RULE IS MADE TESTABLE
|
||||
# -------------------------------------
|
||||
# "An agent must not do X" cannot be proven by observing that no agent did X today.
|
||||
# What CAN be proven — and is what actually protects the spec — is that the MECHANISM
|
||||
# which detects and blocks a spec modification works, and is wired into the merge gate
|
||||
# as a blocking stage. So this test drives the real `ci/spec-immutability.sh` against
|
||||
# HERMETIC THROWAWAY spec repositories and asserts its exit-code behaviour.
|
||||
#
|
||||
# THE REAL SPEC REPO IS NEVER TOUCHED. Every case runs against a fixture git repo under
|
||||
# mktemp, with SPEC_REPO pointed at it and a COPY of ci/ (so the fixture's SPEC_PIN can
|
||||
# be set without editing the tracked one). Nothing here writes outside its temp dir —
|
||||
# which is itself the R-TEST-06 discipline this test exists to defend.
|
||||
#
|
||||
# GIVEN the enforcement mechanism (ci/spec-immutability.sh + ci/SPEC_PIN),
|
||||
# WHEN it is run against a spec repo in each state,
|
||||
# THEN (1) clean and at the pinned SHA -> exit 0 (pass)
|
||||
# (2) an UNCOMMITTED edit under docs/ -> exit 1 (blocks the merge)
|
||||
# (3) an uncommitted edit under decisions/ -> exit 1 (accepted ADRs are spec too)
|
||||
# (4) a NEW COMMIT (HEAD drifted from pin) -> exit 1 (an "adopted" change must be
|
||||
# an explicit reviewed SPEC_PIN bump)
|
||||
# (5) an untracked file added to the spec -> exit 1 (porcelain catches additions)
|
||||
# (6) the spec repo absent -> exit 3 (honest SKIP, never green)
|
||||
# (7) an empty/comment-only SPEC_PIN -> exit 1 (an unpinned gate is no gate)
|
||||
# AND (8) the tracked ci/SPEC_PIN really is a 40-hex commit SHA, and
|
||||
# (9) the gate wires spec-immutability as a stage whose FAIL sets the merge block —
|
||||
# i.e. the mechanism above is load-bearing, not dead code.
|
||||
#
|
||||
# WHAT THIS DOES *NOT* PROVE (honest gap):
|
||||
# - That the Gitea server actually REQUIRES this status check before merge. Branch
|
||||
# protection is operator configuration outside the repo; nothing in-repo can assert it.
|
||||
# - Intent: an agent could still open a spec PR. The rule is enforced by the gate
|
||||
# blocking it, which is exactly what (1)-(9) assert; it is not enforced by prediction.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
CI_SRC="${ROOT}/ci"
|
||||
|
||||
PASS=0
|
||||
FAILED=0
|
||||
note() { printf ' ok %s\n' "$*"; PASS=$((PASS + 1)); }
|
||||
bad() { printf ' FAIL %s\n' "$*" >&2; FAILED=$((FAILED + 1)); }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
# --- a hermetic stand-in for the spec repo -----------------------------------------
|
||||
# Mirrors the real spec's shape: a docs/ tree and a decisions/ tree, one commit.
|
||||
make_spec_repo() {
|
||||
local d="$1"
|
||||
mkdir -p "${d}/docs" "${d}/decisions"
|
||||
# Fixture prose deliberately carries NO R-* token: ci/touched-requirements.sh scans the
|
||||
# non-comment lines of changed files, so an example ID here would be attributed to the
|
||||
# change as a touched requirement that does not exist in the registry.
|
||||
printf 'normative spec text (fixture)\n' > "${d}/docs/00-vision-and-scope.md"
|
||||
printf 'accepted decision\n' > "${d}/decisions/adr-0001-example.md"
|
||||
git -C "$d" init -q -b main
|
||||
git -C "$d" config user.email [email protected]
|
||||
git -C "$d" config user.name spec
|
||||
git -C "$d" config commit.gpgsign false # throwaway: never sign, ignore host gpg config
|
||||
git -C "$d" add -A
|
||||
git -C "$d" commit -qm "spec baseline"
|
||||
}
|
||||
|
||||
# A COPY of ci/ whose SPEC_PIN we may set freely. The script resolves CI_DIR from its own
|
||||
# location and sources lib.sh + versions.env from there, so a copy is self-contained.
|
||||
make_ci_copy() {
|
||||
local dst="$1" pin="$2"
|
||||
mkdir -p "$dst"
|
||||
cp "${CI_SRC}/spec-immutability.sh" "${CI_SRC}/lib.sh" "${CI_SRC}/versions.env" "$dst/"
|
||||
printf '%s\n' "$pin" > "${dst}/SPEC_PIN"
|
||||
chmod +x "${dst}/spec-immutability.sh"
|
||||
}
|
||||
|
||||
# run_case <expected-rc> <description> <spec-repo|-> <pin>
|
||||
run_case() {
|
||||
local want="$1" desc="$2" spec="$3" pin="$4" rc=0
|
||||
local cidir; cidir="$(mktemp -d -p "$WORK" ci.XXXXXX)"
|
||||
make_ci_copy "$cidir" "$pin"
|
||||
SPEC_REPO="$spec" REPO_ROOT="$WORK" "${cidir}/spec-immutability.sh" >/dev/null 2>&1 || rc=$?
|
||||
if [ "$rc" = "$want" ]; then note "${desc} -> exit ${rc}"; else
|
||||
bad "${desc}: expected exit ${want}, got ${rc}"; fi
|
||||
}
|
||||
|
||||
for f in spec-immutability.sh lib.sh versions.env SPEC_PIN; do
|
||||
if [ -f "${CI_SRC}/${f}" ]; then note "ci/${f} present"; else bad "ci/${f} is missing"; fi
|
||||
done
|
||||
|
||||
SPEC="${WORK}/spec"
|
||||
make_spec_repo "$SPEC"
|
||||
PINNED="$(git -C "$SPEC" rev-parse HEAD)"
|
||||
|
||||
# (1) clean + at the pin -> pass.
|
||||
run_case 0 "clean spec at the pinned SHA passes" "$SPEC" "$PINNED"
|
||||
|
||||
# (2) an uncommitted edit under docs/ -> block.
|
||||
printf 'normative spec text REWRITTEN BY AN AGENT\n' > "${SPEC}/docs/00-vision-and-scope.md"
|
||||
run_case 1 "uncommitted edit under docs/ BLOCKS" "$SPEC" "$PINNED"
|
||||
git -C "$SPEC" checkout -q -- docs/00-vision-and-scope.md
|
||||
|
||||
# (3) an uncommitted edit to an accepted ADR -> block (decisions/ is spec too).
|
||||
printf 'reversed decision\n' > "${SPEC}/decisions/adr-0001-example.md"
|
||||
run_case 1 "uncommitted edit under decisions/ BLOCKS" "$SPEC" "$PINNED"
|
||||
git -C "$SPEC" checkout -q -- decisions/adr-0001-example.md
|
||||
|
||||
# (5) an untracked addition to the spec tree -> block.
|
||||
printf 'a spec doc an agent invented\n' > "${SPEC}/docs/99-invented.md"
|
||||
run_case 1 "untracked file added to the spec BLOCKS" "$SPEC" "$PINNED"
|
||||
rm -f "${SPEC}/docs/99-invented.md"
|
||||
|
||||
# clean again -> still passes (proves cases 2/3/5 failed for the edit, not permanently).
|
||||
run_case 0 "spec restored to clean passes again" "$SPEC" "$PINNED"
|
||||
|
||||
# (4) a committed spec change that the pin has not adopted -> block.
|
||||
printf 'an extra normative line added by an agent\n' >> "${SPEC}/docs/00-vision-and-scope.md"
|
||||
git -C "$SPEC" add -A
|
||||
git -C "$SPEC" commit -qm "agent edits the spec"
|
||||
DRIFTED="$(git -C "$SPEC" rev-parse HEAD)"
|
||||
run_case 1 "committed spec drift from the pinned SHA BLOCKS" "$SPEC" "$PINNED"
|
||||
# ...and adopting it EXPLICITLY (a reviewed SPEC_PIN bump) is the only way to green.
|
||||
run_case 0 "an explicit SPEC_PIN bump adopts the advance" "$SPEC" "$DRIFTED"
|
||||
|
||||
# (6) spec repo unavailable -> honest SKIP (exit 3), never a pass.
|
||||
run_case 3 "absent spec repo SKIPS (exit 3), never green" "${WORK}/no-such-spec" "$PINNED"
|
||||
|
||||
# (7) an unpinned gate is not a gate.
|
||||
run_case 1 "empty SPEC_PIN FAILS closed" "$SPEC" "# only a comment, no SHA"
|
||||
|
||||
# (8) the tracked pin is a real commit SHA.
|
||||
REAL_PIN="$(grep -vE '^\s*#' "${CI_SRC}/SPEC_PIN" | tr -d '[:space:]')"
|
||||
if printf '%s' "$REAL_PIN" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
note "ci/SPEC_PIN is a 40-hex commit SHA"
|
||||
else
|
||||
bad "ci/SPEC_PIN is not a 40-hex commit SHA: '${REAL_PIN}'"
|
||||
fi
|
||||
|
||||
# (9) the mechanism is load-bearing: the merge gate runs it as a stage AND a FAIL there
|
||||
# sets the block. Asserted against the gate source because running the whole gate
|
||||
# from inside a conformance check would recurse into this very check.
|
||||
GATE="${CI_SRC}/gate.sh"
|
||||
if grep -Eq '^[[:space:]]*run_stage[[:space:]]+spec-immutability' "$GATE"; then
|
||||
note "ci/gate.sh runs spec-immutability as a gate stage"
|
||||
else
|
||||
bad "ci/gate.sh does not run spec-immutability as a stage — the gate is not wired"
|
||||
fi
|
||||
if awk '/OUTCOME\[spec-immutability\]/,/^esac/' "$GATE" | grep -Eq '^[[:space:]]*fail\).*BLOCK=1'; then
|
||||
note "ci/gate.sh blocks the merge when spec-immutability FAILS"
|
||||
else
|
||||
bad "ci/gate.sh does not set the merge block on a spec-immutability failure"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "check OK: spec-immutability-enforced — ${PASS} assertions (R-TEST-06)"
|
||||
exit 0
|
||||
fi
|
||||
echo "CHECK FAIL (spec-immutability-enforced): ${FAILED} failed, ${PASS} passed" >&2
|
||||
exit 1
|
||||
@@ -1,17 +1,75 @@
|
||||
# =============================================================================
|
||||
# L3 FAULT-INJECTION SCAFFOLD — DR restore (R-STATE-03) *SAFETY-CRITICAL*
|
||||
# Catalog: docs/14 "DR restore (R-STATE-03, L3)"
|
||||
# L3 FAULT-INJECTION — DR restore from an OFF-CLUSTER backup (R-STATE-03)
|
||||
# *SAFETY-CRITICAL* [RUNNABLE / self-skips]
|
||||
# Catalog: docs/14-testing-and-conformance.md "DR restore (R-STATE-03, L3)"
|
||||
# DoD: agents/definition-of-done.md — "DR-critical data restores from an
|
||||
# **off-cluster** backup (drill for at least one service/phase)."
|
||||
# Spec: docs/04-storage-and-state.md; ADR-0003 (Longhorn + Velero).
|
||||
# Impl: platform/velero/** (Velero + kopia File System Backup -> the versitygw S3
|
||||
# endpoint on the Proxmox HOST), reconciled by clusters/base/backup.yaml.
|
||||
#
|
||||
# GIVEN an OFF-CLUSTER backup of a namespace + volumes,
|
||||
# WHEN the namespace + volumes are restored into a CLEAN state,
|
||||
# THEN the service returns with its data.
|
||||
# GIVEN a DR-critical stateful workload with known data on the replicated `longhorn`
|
||||
# tier, and a Velero BackupStorageLocation whose object-store endpoint is
|
||||
# PROVABLY OUTSIDE this cluster,
|
||||
# WHEN a backup of that namespace + its volume data is written to that off-cluster
|
||||
# store, and then EVERY in-cluster copy of the data is DESTROYED (namespace,
|
||||
# PVC and the underlying PersistentVolume/Longhorn replicas all deleted),
|
||||
# THEN a Velero restore into a CLEAN, previously-nonexistent namespace brings the
|
||||
# workload back and the known data reads back byte-identical.
|
||||
#
|
||||
# R-TEST-05: proves recoverability by restoring into a genuinely clean namespace
|
||||
# (not by reading the still-live original). Backup TARGET is OQ-BKP (OPEN) — the
|
||||
# restore mechanism is Velero (T-4.3/T-4.4); this scaffold is target-agnostic.
|
||||
# R-TEST-05 (fault injection, not happy-path): the restore is only meaningful because
|
||||
# the source is annihilated first. After the destroy step there is no PVC, no PV and
|
||||
# no Longhorn volume left in the cluster holding this data — so the bytes that come
|
||||
# back can ONLY have come from the off-cluster object store. A restore that silently
|
||||
# rebound a surviving in-cluster PV is explicitly excluded: the test captures the
|
||||
# original PV name, asserts it is GONE, and asserts the restored PVC is bound to a
|
||||
# DIFFERENT, newly provisioned PV.
|
||||
#
|
||||
# STATUS: scaffold — needs T-4.3 (Velero + off-cluster schedule) + T-4.4 (drill).
|
||||
# BLOCKED-BY spec open question OQ-BKP (off-cluster backup target) — see report.
|
||||
# HOW "OFF-CLUSTER" IS PROVEN (not assumed) — step `assert-backup-target-is-off-cluster`:
|
||||
# 1. The BackupStorageLocation reports phase `Available` — Velero itself has
|
||||
# successfully reached and validated that object store.
|
||||
# 2. Its endpoint host is resolved to an IPv4 address and asserted to be NONE of:
|
||||
# loopback; any Node InternalIP/ExternalIP; any Service ClusterIP; any Pod IP;
|
||||
# inside any node's `spec.podCIDR(s)`; inside any `ServiceCIDR` (best-effort).
|
||||
# It is also asserted not to be a cluster-internal DNS name (*.svc,
|
||||
# *.cluster.local, localhost). => the bytes leave the cluster's address space.
|
||||
# 3. The backup's PodVolumeBackup carries a non-empty `status.snapshotID` — the
|
||||
# kopia snapshot really landed in the remote repository, i.e. VOLUME DATA (not
|
||||
# merely Kubernetes resource JSON) was uploaded off-cluster.
|
||||
# 4. Plus the destroy-then-restore sequence above, which removes every in-cluster
|
||||
# alternative source for the data.
|
||||
#
|
||||
# KNOWN, DELIBERATE GAPS (asserted nowhere — do not read this test as claiming them):
|
||||
# * OFF-HOST is NOT proven, only OFF-CLUSTER. On the dev estate the k3s nodes are
|
||||
# VMs on the same Proxmox host that runs the versitygw S3 target (192.168.1.9),
|
||||
# so this drill does not survive loss of that hypervisor. Off-site/off-host
|
||||
# replication is an operator concern outside R-STATE-03's wording.
|
||||
# * The test does NOT read the backup objects out of the bucket with an S3 client.
|
||||
# Doing so would require handing the conformance runner the backup credential,
|
||||
# widening a privileged test identity for weaker evidence than (1)-(4) above.
|
||||
# * The test restores its OWN scratch workload, never a real service's data. A
|
||||
# per-service drill (Nextcloud/Gitea/CNPG) stays an operator exercise.
|
||||
# * Bare-metal DR (rebuild the cluster itself, then restore) is NOT exercised; this
|
||||
# asserts namespace+volume restore into a live cluster, which is what the catalog
|
||||
# entry and the DoD line ask for.
|
||||
# * Backup ENCRYPTION at rest and retention/GC behaviour are not asserted.
|
||||
#
|
||||
# RUNNABLE / SELF-SKIPPING (a skip is honest; a fake pass is a defect). Skips CLEANLY
|
||||
# (green) when a precondition is absent: no `longhorn` StorageClass; no Velero API
|
||||
# (CRDs absent); no BackupStorageLocation configured at all; Velero server or its
|
||||
# node-agent DaemonSet not ready. It runs the full drill the moment those exist — no
|
||||
# edit needed. RBAC denial is NOT a skip: it fails loudly as a harness defect.
|
||||
#
|
||||
# DESTRUCTIVE BY DESIGN, SCOPED BY CONSTRUCTION: every object it destroys is one it
|
||||
# created itself, in two namespaces it names after its own Chainsaw namespace
|
||||
# (`<ns>-dr-src`, `<ns>-dr-dst`) and labels `conformance.home.k3s/created-by=<ns>`.
|
||||
# It never touches a real service's namespace, PVC or PV, and it never deletes a
|
||||
# Backup it did not create.
|
||||
#
|
||||
# TEARDOWN ALWAYS RUNS (`finally`, even on failure/timeout/panic): both scratch
|
||||
# namespaces, any leftover PV, the Restore CR, and the Backup — the latter via a
|
||||
# Velero DeleteBackupRequest so the objects are removed from the bucket too, with a
|
||||
# short `ttl` on the Backup as a belt-and-braces backstop if that request is lost.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
@@ -23,31 +81,603 @@ metadata:
|
||||
conformance.home.k3s/requirements: "R-STATE-03"
|
||||
conformance.home.k3s/layer: "L3"
|
||||
conformance.home.k3s/safety-critical: "true"
|
||||
# executable => runs against a cluster; self-skips (green) until Velero + a
|
||||
# BackupStorageLocation + the longhorn tier exist.
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
Restore a namespace + volumes from an off-cluster backup into a clean state;
|
||||
the service returns with its data.
|
||||
conformance.home.k3s/blocked-by: "OQ-BKP (off-cluster backup target, spec-open)"
|
||||
Seed known data on longhorn, back the namespace + volume data up to the Velero
|
||||
object store whose endpoint is proven to be outside this cluster, destroy every
|
||||
in-cluster copy (namespace, PVC and PV), restore into a clean namespace, and read
|
||||
the known data back from a newly provisioned volume. Skips cleanly until Velero,
|
||||
a BackupStorageLocation and a longhorn StorageClass exist.
|
||||
conformance.home.k3s/never-executed: >-
|
||||
AUTHORED, NOT YET EXECUTED. No workstation in this repo has cluster access, and
|
||||
the drill is destructive, so it must first be run by the operator against the dev
|
||||
cluster in a maintenance window before R-STATE-03 may be called proven. Repo-derived
|
||||
status deliberately reports proof as not-derivable (conformance/lib/status.py).
|
||||
spec:
|
||||
# A full backup -> destroy -> restore cycle over kopia File System Backup, plus
|
||||
# Longhorn provisioning on HDD, is far slower than any other test here. Bound it
|
||||
# generously but explicitly (no sleep-and-hope; every wait below is a bounded poll).
|
||||
# Worst-case bound of the drill step, summing every poll below (seed ~5m + stale-target
|
||||
# clean ~5m + backup 15m + namespace destroy 12m + PV destroy 5m + restore 15m + restored
|
||||
# workload Ready 10m ~= 67m). Declared at 75m so the step's own bounded polls, not this
|
||||
# timeout, are what fail first and report WHICH phase stalled. Nothing here sleeps blindly.
|
||||
timeouts:
|
||||
exec: 75m
|
||||
delete: 10m
|
||||
cleanup: 15m
|
||||
steps:
|
||||
- name: given-off-cluster-backup-exists
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO(T-4.3): ensure a Velero backup of ns+volumes exists off-cluster"'
|
||||
- name: when-restore-into-clean-namespace
|
||||
# -----------------------------------------------------------------------------
|
||||
# Preconditions + the OFF-CLUSTER property of the backup target. This step makes
|
||||
# no changes to the cluster; it either skips cleanly or proves the target is
|
||||
# genuinely outside the cluster's address space.
|
||||
# -----------------------------------------------------------------------------
|
||||
- name: assert-backup-target-is-off-cluster
|
||||
try:
|
||||
- script:
|
||||
content: |
|
||||
set -euo pipefail
|
||||
echo "TODO(T-4.4): create a clean target ns; trigger Velero restore of ns+PVs"
|
||||
- name: then-service-returns-with-data
|
||||
try:
|
||||
- script:
|
||||
content: |
|
||||
set -euo pipefail
|
||||
echo "TODO: assert app Ready in restored ns AND canary data present"
|
||||
echo "DR_RESTORE_OK"
|
||||
set -eu
|
||||
|
||||
# ---- helpers -------------------------------------------------------
|
||||
# Classify a kubectl failure: an RBAC denial is a HARNESS DEFECT and must
|
||||
# fail loudly; an absent API is a genuine precondition gap and may skip.
|
||||
die_or_skip() {
|
||||
case "$2" in
|
||||
*orbidden*|*Unauthorized*|*cannot\ list*|*cannot\ get*)
|
||||
echo "FAIL: conformance runner lacks RBAC for $1 -- fix conformance/incluster/rbac.yaml" >&2
|
||||
echo "$2" >&2
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "SKIP: $1 not available on this cluster -- $2"
|
||||
echo DR_RESTORE_SKIPPED_OK
|
||||
exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ip2int() {
|
||||
_q="$(echo "$1" | tr '.' ' ')"
|
||||
set -- $_q
|
||||
[ $# -eq 4 ] || { echo -1; return 0; }
|
||||
echo $(( ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 ))
|
||||
}
|
||||
|
||||
# in_cidr <ip> <a.b.c.d/len> -> 0 when the ip is inside the prefix
|
||||
in_cidr() {
|
||||
case "$2" in *"/"*) ;; *) return 1 ;; esac
|
||||
_net="${2%/*}"; _len="${2#*/}"
|
||||
case "$_len" in ''|*[!0-9]*) return 1 ;; esac
|
||||
[ "$_len" -ge 0 ] && [ "$_len" -le 32 ] || return 1
|
||||
_i="$(ip2int "$1")"; _n="$(ip2int "$_net")"
|
||||
[ "$_i" -ge 0 ] && [ "$_n" -ge 0 ] || return 1
|
||||
[ "$_len" -eq 0 ] && return 0
|
||||
_d=1; _s=0
|
||||
while [ "$_s" -lt $((32 - _len)) ]; do _d=$((_d * 2)); _s=$((_s + 1)); done
|
||||
[ $(( _i / _d )) -eq $(( _n / _d )) ]
|
||||
}
|
||||
|
||||
# ---- Gate 1: the replicated storage tier the DR-critical data lives on --
|
||||
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
|
||||
echo "SKIP: 'longhorn' StorageClass absent -- no DR-critical data tier on this cluster yet"
|
||||
echo DR_RESTORE_SKIPPED_OK
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Gate 2: the Velero API + a configured BackupStorageLocation --------
|
||||
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.provider}{" "}{.spec.default}{" "}{.spec.config.s3Url}{" "}{.spec.objectStorage.bucket}{"\n"}{end}' \
|
||||
2>&1)" || die_or_skip "velero.io/backupstoragelocations" "$BSL_LINES"
|
||||
if [ -z "$(echo "$BSL_LINES" | tr -d '[:space:]')" ]; then
|
||||
echo "SKIP: Velero API present but NO BackupStorageLocation is configured -- no off-cluster backup target yet"
|
||||
echo DR_RESTORE_SKIPPED_OK
|
||||
exit 0
|
||||
fi
|
||||
echo "backup storage locations:"
|
||||
echo "$BSL_LINES"
|
||||
|
||||
VNS="$(echo "$BSL_LINES" | head -1 | cut -d' ' -f1)"
|
||||
|
||||
# ---- Gate 3: Velero server + node-agent (kopia FSB) actually ready -------
|
||||
# File System Backup is the volume-DATA path (platform/velero/helmrelease.yaml:
|
||||
# deployNodeAgent=true, uploaderType=kopia, snapshotsEnabled=false). Without a
|
||||
# ready node-agent no volume data can be captured, so the drill is not runnable.
|
||||
VREADY=0; NAREADY=0
|
||||
for i in $(seq 1 12); do
|
||||
VREADY="$(kubectl -n "$VNS" get deploy velero -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true)"
|
||||
NAREADY="$(kubectl -n "$VNS" get daemonset node-agent -o jsonpath='{.status.numberReady}' 2>/dev/null || true)"
|
||||
echo "poll $i: velero readyReplicas=${VREADY:-0} node-agent numberReady=${NAREADY:-0}"
|
||||
if [ "${VREADY:-0}" -ge 1 ] && [ "${NAREADY:-0}" -ge 1 ]; then break; fi
|
||||
sleep 10
|
||||
done
|
||||
if [ "${VREADY:-0}" -lt 1 ] || [ "${NAREADY:-0}" -lt 1 ]; then
|
||||
echo "SKIP: Velero server and/or its node-agent DaemonSet are not ready in ns '$VNS' -- backup/restore path not available"
|
||||
echo DR_RESTORE_SKIPPED_OK
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Pick the BSL this drill will use: prefer the default, and it MUST be
|
||||
# Available. A CONFIGURED-BUT-UNAVAILABLE target is NOT a precondition
|
||||
# gap -- it is a live R-STATE-03 defect, so it FAILS (after a bounded
|
||||
# wait for first-validation on a cold start), it does not skip. --------
|
||||
BSL_NS=""; BSL_NAME=""; BSL_URL=""; BSL_BUCKET=""
|
||||
for i in $(seq 1 12); do
|
||||
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.provider}{" "}{.spec.default}{" "}{.spec.config.s3Url}{" "}{.spec.objectStorage.bucket}{"\n"}{end}' 2>/dev/null || true)"
|
||||
CAND="$(echo "$BSL_LINES" | grep ' Available ' | grep ' true ' | head -1 || true)"
|
||||
[ -n "$CAND" ] || CAND="$(echo "$BSL_LINES" | grep ' Available ' | head -1 || true)"
|
||||
if [ -n "$CAND" ]; then
|
||||
BSL_NS="$(echo "$CAND" | cut -d' ' -f1)"
|
||||
BSL_NAME="$(echo "$CAND" | cut -d' ' -f2)"
|
||||
BSL_URL="$(echo "$CAND" | cut -d' ' -f6)"
|
||||
BSL_BUCKET="$(echo "$CAND" | cut -d' ' -f7)"
|
||||
break
|
||||
fi
|
||||
echo "poll $i: no Available BackupStorageLocation yet"
|
||||
sleep 10
|
||||
done
|
||||
if [ -z "$BSL_NAME" ]; then
|
||||
echo "FAIL: a BackupStorageLocation is configured but NONE reached phase 'Available'." >&2
|
||||
echo " The off-cluster backup target is unreachable -- R-STATE-03 is BROKEN, not merely untested." >&2
|
||||
echo "$BSL_LINES" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "using BSL ${BSL_NS}/${BSL_NAME} bucket=${BSL_BUCKET} endpoint=${BSL_URL}"
|
||||
|
||||
# ---- OFF-CLUSTER PROOF (1): an endpoint we can evaluate ------------------
|
||||
if [ -z "$BSL_URL" ]; then
|
||||
echo "FAIL: BackupStorageLocation ${BSL_NS}/${BSL_NAME} exposes no spec.config.s3Url;" >&2
|
||||
echo " the OFF-CLUSTER property of the backup target cannot be established, so this" >&2
|
||||
echo " test refuses to claim it. (Extend this check for a non-S3 provider.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
HOST="${BSL_URL#*://}" # strip scheme
|
||||
HOST="${HOST%%/*}" # strip path
|
||||
HOST="${HOST%%:*}" # strip port
|
||||
echo "backup endpoint host: $HOST"
|
||||
|
||||
case "$HOST" in
|
||||
localhost|*.svc|*.svc.cluster.local|*.cluster.local)
|
||||
echo "FAIL: backup endpoint '$HOST' is a CLUSTER-INTERNAL name -- the backup target is IN the cluster." >&2
|
||||
echo " An in-cluster backup store cannot satisfy R-STATE-03 (off-cluster backup)." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Resolve to an IPv4 address. If it cannot be resolved we CANNOT establish the
|
||||
# off-cluster property -- that is a failure, never a silent pass.
|
||||
case "$HOST" in
|
||||
*[!0-9.]*)
|
||||
# Endpoint given as a name. Try the resolvers that may exist in the
|
||||
# runner image, in order; if none is available or none resolves, the
|
||||
# test FAILS below rather than guessing.
|
||||
EPIP="$(getent ahostsv4 "$HOST" 2>/dev/null | head -1 | cut -d' ' -f1 || true)"
|
||||
[ -n "$EPIP" ] || EPIP="$(getent hosts "$HOST" 2>/dev/null | head -1 | cut -d' ' -f1 || true)"
|
||||
[ -n "$EPIP" ] || EPIP="$(nslookup "$HOST" 2>/dev/null | grep -A2 '^Name:' | grep '^Address' | head -1 | sed 's/.*: *//' || true)" ;;
|
||||
*) EPIP="$HOST" ;;
|
||||
esac
|
||||
if [ -z "$EPIP" ]; then
|
||||
echo "FAIL: could not resolve backup endpoint host '$HOST' to an IPv4 address;" >&2
|
||||
echo " the OFF-CLUSTER property cannot be asserted, so this test does not pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "backup endpoint resolves to: $EPIP"
|
||||
|
||||
# ---- OFF-CLUSTER PROOF (2): the endpoint is outside every cluster address --
|
||||
case "$EPIP" in 127.*|0.0.0.0) echo "FAIL: backup endpoint $EPIP is loopback -- not off-cluster" >&2; exit 1 ;; esac
|
||||
|
||||
NODE_IPS="$(kubectl get nodes -o jsonpath='{range .items[*]}{range .status.addresses[*]}{.address}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||
for a in $NODE_IPS; do
|
||||
if [ "$a" = "$EPIP" ]; then
|
||||
echo "FAIL: backup endpoint $EPIP is a NODE address of this cluster -- the backup is not off-cluster." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "endpoint is not any node address (checked $(echo "$NODE_IPS" | grep -c . || true) node addresses)"
|
||||
|
||||
SVC_IPS="$(kubectl get svc -A -o jsonpath='{range .items[*]}{.spec.clusterIP}{"\n"}{end}' 2>/dev/null || true)"
|
||||
for a in $SVC_IPS; do
|
||||
if [ "$a" = "$EPIP" ]; then
|
||||
echo "FAIL: backup endpoint $EPIP is a Service ClusterIP -- the backup target lives INSIDE the cluster." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "endpoint is not any Service ClusterIP (checked $(echo "$SVC_IPS" | grep -c . || true))"
|
||||
|
||||
POD_IPS="$(kubectl get pods -A -o jsonpath='{range .items[*]}{.status.podIP}{"\n"}{end}' 2>/dev/null || true)"
|
||||
for a in $POD_IPS; do
|
||||
if [ "$a" = "$EPIP" ]; then
|
||||
echo "FAIL: backup endpoint $EPIP is a POD IP -- the backup target is an in-cluster pod." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "endpoint is not any Pod IP (checked $(echo "$POD_IPS" | grep -c . || true))"
|
||||
|
||||
POD_CIDRS="$(kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{range .spec.podCIDRs[*]}{@}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||
for c in $POD_CIDRS; do
|
||||
case "$c" in *:*) continue ;; esac # IPv6 prefixes are out of scope here
|
||||
if in_cidr "$EPIP" "$c"; then
|
||||
echo "FAIL: backup endpoint $EPIP falls inside node podCIDR $c -- it is in the cluster pod network." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "endpoint is outside every node podCIDR (checked: $(echo "$POD_CIDRS" | tr '\n' ' '))"
|
||||
|
||||
# Best-effort (needs networking.k8s.io/ServiceCIDR, which the least-privilege
|
||||
# runner may not read): the checks above are the authoritative ones.
|
||||
SVC_CIDRS="$(kubectl get servicecidrs.networking.k8s.io -o jsonpath='{range .items[*]}{range .spec.cidrs[*]}{@}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||
if [ -n "$SVC_CIDRS" ]; then
|
||||
for c in $SVC_CIDRS; do
|
||||
case "$c" in *:*) continue ;; esac
|
||||
if in_cidr "$EPIP" "$c"; then
|
||||
echo "FAIL: backup endpoint $EPIP falls inside the Service CIDR $c -- it is in the cluster." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "endpoint is outside every ServiceCIDR ($(echo "$SVC_CIDRS" | tr '\n' ' '))"
|
||||
else
|
||||
echo "(ServiceCIDR objects unreadable -- skipping that best-effort check; ClusterIP/PodIP/podCIDR checks stand)"
|
||||
fi
|
||||
|
||||
echo "OFF_CLUSTER_TARGET_OK bsl=${BSL_NS}/${BSL_NAME} endpoint=${EPIP} bucket=${BSL_BUCKET}"
|
||||
check:
|
||||
(contains($stdout, 'DR_RESTORE_OK')): true
|
||||
finally:
|
||||
# green on either a clean precondition skip or a proven off-cluster target.
|
||||
(contains($stdout, 'OFF_CLUSTER_TARGET_OK') || contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# R-STATE-03's "scheduled" half: the backup is not a one-off human action.
|
||||
# Non-destructive, and skipped in lock-step with the step above.
|
||||
# -----------------------------------------------------------------------------
|
||||
- name: assert-backups-are-scheduled
|
||||
try:
|
||||
- script:
|
||||
content: 'echo "TODO: delete the restore target namespace (self-cleaning)"'
|
||||
content: |
|
||||
set -eu
|
||||
SCHED="$(kubectl get schedules.velero.io -A \
|
||||
-o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{" phase="}{.status.phase}{" cron="}{.spec.schedule}{" lastBackup="}{.status.lastBackup}{"\n"}{end}' 2>/dev/null || true)"
|
||||
if [ -z "$(echo "$SCHED" | tr -d '[:space:]')" ]; then
|
||||
# Distinguish "Velero absent" (skip, in lock-step with step 1) from
|
||||
# "Velero present but nothing scheduled" (a real R-STATE-03 defect).
|
||||
if kubectl get backupstoragelocations.velero.io -A >/dev/null 2>&1; then
|
||||
if [ -z "$(kubectl get backupstoragelocations.velero.io -A -o name 2>/dev/null || true)" ]; then
|
||||
echo "SKIP: no BackupStorageLocation configured -- scheduling not applicable yet"
|
||||
echo DR_SCHEDULE_SKIPPED_OK
|
||||
exit 0
|
||||
fi
|
||||
echo "FAIL: Velero is configured with a backup target but declares NO Schedule --" >&2
|
||||
echo " R-STATE-03 requires SCHEDULED off-cluster backups, not manual ones." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SKIP: Velero API absent -- backup scheduling not applicable on this cluster yet"
|
||||
echo DR_SCHEDULE_SKIPPED_OK
|
||||
exit 0
|
||||
fi
|
||||
echo "velero schedules:"
|
||||
echo "$SCHED"
|
||||
# A Schedule that exists but is paused/disabled backs nothing up.
|
||||
if echo "$SCHED" | grep -q 'phase=Enabled'; then
|
||||
echo BACKUPS_ARE_SCHEDULED_OK
|
||||
else
|
||||
echo "FAIL: no Velero Schedule is in phase 'Enabled' -- scheduled off-cluster backups are not running." >&2
|
||||
exit 1
|
||||
fi
|
||||
check:
|
||||
(contains($stdout, 'BACKUPS_ARE_SCHEDULED_OK') || contains($stdout, 'DR_SCHEDULE_SKIPPED_OK')): true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The drill itself: seed -> back up off-cluster -> ANNIHILATE -> restore -> read.
|
||||
# -----------------------------------------------------------------------------
|
||||
- name: data-restores-from-the-off-cluster-backup
|
||||
try:
|
||||
- script:
|
||||
content: |
|
||||
set -eu
|
||||
NS="$NAMESPACE"
|
||||
SRC="${NS}-dr-src" # scratch source namespace (destroyed by design)
|
||||
DST="${NS}-dr-dst" # clean restore target (must not pre-exist)
|
||||
BK="${NS}-dr-backup"
|
||||
RS="${NS}-dr-restore"
|
||||
OWNER_LABEL="conformance.home.k3s/created-by=${NS}"
|
||||
|
||||
# ---- Re-derive the preconditions (steps do not share shell state). Every
|
||||
# skip below mirrors step 1 exactly, so the whole test skips or runs
|
||||
# as a unit -- it can never half-run. -----------------------------
|
||||
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
|
||||
echo "SKIP: 'longhorn' StorageClass absent"; echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||
fi
|
||||
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.default}{"\n"}{end}' 2>/dev/null || true)"
|
||||
CAND="$(echo "$BSL_LINES" | grep ' Available ' | grep ' true$' | head -1 || true)"
|
||||
[ -n "$CAND" ] || CAND="$(echo "$BSL_LINES" | grep ' Available ' | head -1 || true)"
|
||||
if [ -z "$CAND" ]; then
|
||||
echo "SKIP: no Available BackupStorageLocation (see the off-cluster step for the authoritative verdict)"
|
||||
echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||
fi
|
||||
VNS="$(echo "$CAND" | cut -d' ' -f1)"
|
||||
BSL_NAME="$(echo "$CAND" | cut -d' ' -f2)"
|
||||
if [ "$(kubectl -n "$VNS" get daemonset node-agent -o jsonpath='{.status.numberReady}' 2>/dev/null || echo 0)" -lt 1 ]; then
|
||||
echo "SKIP: Velero node-agent (kopia FSB) not ready -- volume data cannot be backed up"
|
||||
echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||
fi
|
||||
echo "velero ns=$VNS bsl=$BSL_NAME src=$SRC dst=$DST"
|
||||
|
||||
# A clean restore target means PREVIOUSLY NONEXISTENT, and the source name must
|
||||
# be free. If either is lying around from an aborted run, remove it before we
|
||||
# claim "clean" -- and fail loudly if it will not go, because a half-deleted
|
||||
# namespace would silently invalidate the destroy proof below.
|
||||
kubectl delete namespace "$DST" "$SRC" --ignore-not-found --wait=true --timeout=300s
|
||||
|
||||
# ============================ GIVEN ==============================
|
||||
# A DR-critical stateful workload with known data on the replicated tier.
|
||||
CANARY="dr-canary-$(date +%s%N)"
|
||||
kubectl create namespace "$SRC"
|
||||
kubectl label namespace "$SRC" "$OWNER_LABEL" --overwrite
|
||||
|
||||
kubectl -n "$SRC" apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: dr-data
|
||||
labels:
|
||||
conformance.home.k3s/created-by: ${NS}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
EOF
|
||||
|
||||
kubectl -n "$SRC" apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: dr-workload
|
||||
labels:
|
||||
app: dr-workload
|
||||
conformance.home.k3s/created-by: ${NS}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: app
|
||||
image: busybox:1.36.1
|
||||
command: ["sh", "-c", "sleep 7200"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: dr-data
|
||||
EOF
|
||||
|
||||
kubectl -n "$SRC" wait --for=condition=Ready pod/dr-workload --timeout=300s
|
||||
printf '%s' "$CANARY" | kubectl -n "$SRC" exec -i dr-workload -- sh -c 'cat > /data/canary && sync'
|
||||
echo "seeded canary: $(kubectl -n "$SRC" exec dr-workload -- cat /data/canary)"
|
||||
|
||||
ORIG_PV="$(kubectl -n "$SRC" get pvc dr-data -o jsonpath='{.spec.volumeName}')"
|
||||
[ -n "$ORIG_PV" ] || { echo "FAIL: source PVC never bound to a PV" >&2; exit 1; }
|
||||
echo "original in-cluster PersistentVolume: $ORIG_PV"
|
||||
|
||||
# ======================= WHEN (part 1): back up off-cluster =======
|
||||
# Created as a Velero Backup CR -- exactly what `velero backup create` does.
|
||||
# defaultVolumesToFsBackup:true selects the kopia File System Backup path, so
|
||||
# the VOLUME DATA (not just the resource JSON) is uploaded to the object store.
|
||||
# ttl is short: if the finally-step DeleteBackupRequest is ever lost, Velero's
|
||||
# own GC still removes this drill's objects from the bucket.
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: velero.io/v1
|
||||
kind: Backup
|
||||
metadata:
|
||||
name: ${BK}
|
||||
namespace: ${VNS}
|
||||
labels:
|
||||
conformance.home.k3s/created-by: ${NS}
|
||||
spec:
|
||||
includedNamespaces: [${SRC}]
|
||||
storageLocation: ${BSL_NAME}
|
||||
snapshotVolumes: false
|
||||
defaultVolumesToFsBackup: true
|
||||
ttl: 2h0m0s
|
||||
EOF
|
||||
|
||||
BPHASE=""
|
||||
for i in $(seq 1 90); do
|
||||
BPHASE="$(kubectl -n "$VNS" get backups.velero.io "$BK" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
|
||||
echo "poll $i: backup phase=${BPHASE:-<pending>}"
|
||||
case "${BPHASE:-}" in
|
||||
Completed|Failed|PartiallyFailed|FailedValidation) break ;;
|
||||
esac
|
||||
sleep 10
|
||||
done
|
||||
if [ "${BPHASE:-}" != "Completed" ]; then
|
||||
echo "FAIL: off-cluster backup did not complete (phase=${BPHASE:-<timeout>})" >&2
|
||||
kubectl -n "$VNS" get backups.velero.io "$BK" -o yaml >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "backup $BK Completed to ${BSL_NAME}"
|
||||
|
||||
# ---- OFF-CLUSTER PROOF (3): the VOLUME DATA really landed in the remote
|
||||
# kopia repository -- a PodVolumeBackup Completed with a snapshot ID.
|
||||
PVB="$(kubectl -n "$VNS" get podvolumebackups.velero.io \
|
||||
-l velero.io/backup-name="$BK" \
|
||||
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.phase}{" "}{.status.snapshotID}{"\n"}{end}' 2>/dev/null || true)"
|
||||
echo "pod volume backups:"
|
||||
echo "$PVB"
|
||||
if [ -z "$(echo "$PVB" | tr -d '[:space:]')" ]; then
|
||||
echo "FAIL: the backup captured NO pod volume data (no PodVolumeBackup)." >&2
|
||||
echo " Resource-only backups cannot restore DR-critical DATA." >&2
|
||||
exit 1
|
||||
fi
|
||||
# A line is "<name> <phase> <snapshotID>"; require Completed AND a
|
||||
# non-empty snapshot ID (an empty ID means nothing reached the repository).
|
||||
GOOD_PVB="$(echo "$PVB" | grep -cE ' Completed [^[:space:]]+$' || true)"
|
||||
if [ "${GOOD_PVB:-0}" -lt 1 ]; then
|
||||
echo "FAIL: no PodVolumeBackup reached phase Completed with a snapshot ID --" >&2
|
||||
echo " the volume data did not reach the off-cluster repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "volume data uploaded off-cluster (PodVolumeBackup Completed with a kopia snapshotID)"
|
||||
|
||||
# ============ WHEN (part 2): FAULT INJECTION -- annihilate the source ====
|
||||
# Destroy EVERY in-cluster copy: the pod, the PVC, the namespace, and the
|
||||
# underlying PersistentVolume (and with it the Longhorn replicas). After this
|
||||
# there is no in-cluster source the restore could quietly read from.
|
||||
kubectl delete namespace "$SRC" --wait=true --timeout=420s
|
||||
for i in $(seq 1 30); do
|
||||
if ! kubectl get namespace "$SRC" >/dev/null 2>&1; then break; fi
|
||||
echo "poll $i: waiting for namespace $SRC to disappear"
|
||||
sleep 10
|
||||
done
|
||||
if kubectl get namespace "$SRC" >/dev/null 2>&1; then
|
||||
echo "FAIL: source namespace $SRC did not terminate; the destroy step is not convincing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "source namespace destroyed"
|
||||
|
||||
# The longhorn StorageClass reclaims Delete, so the PV should already be gone;
|
||||
# force it if a Retain policy (or a stuck finalizer) left it behind, because a
|
||||
# surviving PV would make the restore prove nothing about off-cluster recovery.
|
||||
kubectl delete pv "$ORIG_PV" --ignore-not-found --wait=false || true
|
||||
for i in $(seq 1 30); do
|
||||
if ! kubectl get pv "$ORIG_PV" >/dev/null 2>&1; then break; fi
|
||||
echo "poll $i: waiting for PersistentVolume $ORIG_PV to disappear"
|
||||
sleep 10
|
||||
done
|
||||
if kubectl get pv "$ORIG_PV" >/dev/null 2>&1; then
|
||||
echo "FAIL: the original PersistentVolume $ORIG_PV SURVIVED the destroy step." >&2
|
||||
echo " An in-cluster copy of the data still exists, so a successful restore" >&2
|
||||
echo " would NOT prove recovery from the off-cluster backup. Refusing to pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "original PersistentVolume $ORIG_PV is GONE -- no in-cluster copy of the data remains"
|
||||
|
||||
# Best-effort corroboration (the least-privilege runner may not read longhorn.io):
|
||||
# the Longhorn Volume CR and its replicas should be gone too.
|
||||
if kubectl -n longhorn-system get volumes.longhorn.io "$ORIG_PV" >/dev/null 2>&1; then
|
||||
echo "FAIL: Longhorn Volume $ORIG_PV still exists after PV deletion -- replicas survive in-cluster" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "(longhorn Volume CR $ORIG_PV not present -- either deleted or not readable by this identity)"
|
||||
|
||||
# ============================ RESTORE ============================
|
||||
# Into a CLEAN, previously-nonexistent namespace (Velero creates it), which is
|
||||
# the catalog's "restored into a clean state".
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: velero.io/v1
|
||||
kind: Restore
|
||||
metadata:
|
||||
name: ${RS}
|
||||
namespace: ${VNS}
|
||||
labels:
|
||||
conformance.home.k3s/created-by: ${NS}
|
||||
spec:
|
||||
backupName: ${BK}
|
||||
includedNamespaces: [${SRC}]
|
||||
namespaceMapping:
|
||||
${SRC}: ${DST}
|
||||
existingResourcePolicy: none
|
||||
EOF
|
||||
|
||||
RPHASE=""
|
||||
for i in $(seq 1 90); do
|
||||
RPHASE="$(kubectl -n "$VNS" get restores.velero.io "$RS" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
|
||||
echo "poll $i: restore phase=${RPHASE:-<pending>}"
|
||||
case "${RPHASE:-}" in
|
||||
Completed|Failed|PartiallyFailed|FailedValidation) break ;;
|
||||
esac
|
||||
sleep 10
|
||||
done
|
||||
if [ "${RPHASE:-}" != "Completed" ]; then
|
||||
echo "FAIL: restore did not complete (phase=${RPHASE:-<timeout>})" >&2
|
||||
kubectl -n "$VNS" get restores.velero.io "$RS" -o yaml >&2 || true
|
||||
kubectl -n "$VNS" get podvolumerestores.velero.io -l velero.io/restore-name="$RS" -o wide >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "restore $RS Completed into $DST"
|
||||
|
||||
# ============================= THEN ==============================
|
||||
# The service returns WITH ITS DATA. With kopia FSB the restored pod carries a
|
||||
# `restore-wait` init container that blocks until the node-agent has written the
|
||||
# volume contents back, so Ready here already implies the data landed.
|
||||
if ! kubectl -n "$DST" wait --for=condition=Ready pod/dr-workload --timeout=600s; then
|
||||
echo "FAIL: restored workload never became Ready in the clean namespace $DST" >&2
|
||||
kubectl -n "$DST" describe pod dr-workload >&2 || true
|
||||
kubectl -n "$VNS" get podvolumerestores.velero.io -l velero.io/restore-name="$RS" -o wide >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_PV="$(kubectl -n "$DST" get pvc dr-data -o jsonpath='{.spec.volumeName}' 2>/dev/null || true)"
|
||||
echo "restored PVC bound to PersistentVolume: ${NEW_PV:-<none>}"
|
||||
[ -n "$NEW_PV" ] || { echo "FAIL: restored PVC did not bind a PV" >&2; exit 1; }
|
||||
if [ "$NEW_PV" = "$ORIG_PV" ]; then
|
||||
echo "FAIL: the restored PVC rebound the ORIGINAL PersistentVolume ($ORIG_PV)." >&2
|
||||
echo " That is an in-cluster survival, not an off-cluster restore." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "restored onto a NEW volume ($NEW_PV != $ORIG_PV) -- the data came from outside the cluster"
|
||||
|
||||
GOT="$(kubectl -n "$DST" exec dr-workload -- cat /data/canary)"
|
||||
echo "recovered='$GOT' expected='$CANARY'"
|
||||
if [ "$GOT" != "$CANARY" ]; then
|
||||
echo "FAIL: restored data does not match what was backed up -- DR restore lost/corrupted data" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK
|
||||
check:
|
||||
# green on either a clean precondition skip or a proven off-cluster restore.
|
||||
(contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK') || contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true
|
||||
finally:
|
||||
# ALWAYS RUNS -- on pass, fail, timeout or panic. This test creates namespaces
|
||||
# and Velero objects OUTSIDE its Chainsaw namespace, so Chainsaw's own namespace
|
||||
# teardown cannot reclaim them; everything below is keyed to names/labels derived
|
||||
# from THIS test's namespace, so it can never touch anything it did not create.
|
||||
- script:
|
||||
content: |
|
||||
set -u
|
||||
NS="$NAMESPACE"
|
||||
SRC="${NS}-dr-src"; DST="${NS}-dr-dst"; BK="${NS}-dr-backup"; RS="${NS}-dr-restore"
|
||||
|
||||
VNS="$(kubectl get backupstoragelocations.velero.io -A -o jsonpath='{.items[0].metadata.namespace}' 2>/dev/null || true)"
|
||||
|
||||
# Velero objects first: a DeleteBackupRequest makes Velero remove the backup
|
||||
# DATA from the off-cluster bucket, not just the CR (self-cleaning contract).
|
||||
if [ -n "$VNS" ]; then
|
||||
kubectl -n "$VNS" delete restores.velero.io "$RS" --ignore-not-found --wait=false || true
|
||||
if kubectl -n "$VNS" get backups.velero.io "$BK" >/dev/null 2>&1; then
|
||||
kubectl apply -f - <<EOF || true
|
||||
apiVersion: velero.io/v1
|
||||
kind: DeleteBackupRequest
|
||||
metadata:
|
||||
name: ${BK}-delete
|
||||
namespace: ${VNS}
|
||||
labels:
|
||||
conformance.home.k3s/created-by: ${NS}
|
||||
spec:
|
||||
backupName: ${BK}
|
||||
EOF
|
||||
for i in $(seq 1 18); do
|
||||
kubectl -n "$VNS" get backups.velero.io "$BK" >/dev/null 2>&1 || break
|
||||
sleep 10
|
||||
done
|
||||
# Backstop if Velero could not process the request (the 2h ttl also GCs it).
|
||||
kubectl -n "$VNS" delete backups.velero.io "$BK" --ignore-not-found --wait=false || true
|
||||
kubectl -n "$VNS" delete deletebackuprequests.velero.io "${BK}-delete" --ignore-not-found --wait=false || true
|
||||
fi
|
||||
echo "velero objects for $NS cleaned up (ns $VNS)"
|
||||
fi
|
||||
|
||||
# Scratch namespaces (both, in case the run aborted at any point).
|
||||
for n in "$DST" "$SRC"; do
|
||||
kubectl delete namespace "$n" --ignore-not-found --wait=false || true
|
||||
done
|
||||
# Anything left labelled as ours (defensive; the two names above are the only
|
||||
# namespaces this test ever creates).
|
||||
kubectl delete namespace -l "conformance.home.k3s/created-by=${NS}" --ignore-not-found --wait=false || true
|
||||
|
||||
# Released PVs from either namespace's PVCs would otherwise leak storage.
|
||||
for pv in $(kubectl get pv -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.claimRef.namespace}{"\n"}{end}' 2>/dev/null | grep -E " (${SRC}|${DST})$" | cut -d' ' -f1 || true); do
|
||||
kubectl delete pv "$pv" --ignore-not-found --wait=false || true
|
||||
echo "released leftover PersistentVolume: $pv"
|
||||
done
|
||||
|
||||
echo "dr-restore teardown complete (best-effort; every object was created by this test)"
|
||||
@@ -8,6 +8,12 @@ reconnects to it and re-establishes the toolchain.
|
||||
> `DECISIONS-LOG.md` entry (2026-08-31)** — it is the current-state + gotchas + prioritized **Next
|
||||
> Steps** handoff — and the plans under `docs/plans/`. Start at Next Steps.
|
||||
|
||||
> **Phase 7 (prod) is OPEN as of 2026-08-31 — prep only.** The first prod node
|
||||
> (`k3s-prod-1`, `192.168.2.10`) is on the bench. This document covers the **dev** cluster;
|
||||
> for prod see [`PROD-PROVISIONING.md`](PROD-PROVISIONING.md) (T-7.1 checklist) and the
|
||||
> Phase-7 entry in `DECISIONS-LOG.md`. Nothing is installed on prod hardware until the
|
||||
> R-ENV-02 tokenization blockers close and the gate is green.
|
||||
|
||||
> **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`,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
# 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:
|
||||
|
||||
- [x] **Drive health MEASURED 2026-09-01** (`smartctl -a /dev/sda`, via the baseline package
|
||||
floor — see §5). Raw attributes, so the numbers are auditable:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Model | Crucial/Micron RealSSD m4/C400/P400 (`C400-MTFDDAC512MAM`, fw `070H`) |
|
||||
| SMART self-assessment | **PASSED** — no attribute in `WHEN_FAILED` |
|
||||
| Power-on hours | 6,814 (~284 days) · 147 power cycles |
|
||||
| **Rated life used** | **31%** (`202`), i.e. 69% remaining (`173` VALUE 069) |
|
||||
| **Grown failing blocks** | **15** (`170`), reallocation events **15** (`196`) |
|
||||
| **Erase failures** | **187** (`172`) |
|
||||
| **Unexpected power losses** | **32** (`174`) |
|
||||
| Pending / offline-uncorrectable / reported-uncorrectable | **0 / 0 / 0** (`197`/`198`/`187`) |
|
||||
| UDMA CRC errors | 0 (`199`) — cabling is clean |
|
||||
|
||||
**Read attribute `5` carefully.** Its raw value is `61440`, which is NOT a bad-sector
|
||||
count: `61440 = 15 x 4096`, and both `170` and `196` independently read **15**. Its
|
||||
normalised VALUE is 100 against a THRESH of 10. So the drive has **15 grown bad blocks**,
|
||||
not sixty-one thousand bad sectors. The first `node_health` run FAILED on that raw value;
|
||||
the check now judges on normalised VALUE/THRESH, `WHEN_FAILED`, and the vendor-independent
|
||||
uncorrectable counters instead.
|
||||
|
||||
**Verdict: usable, but not for production state.** Nothing says imminent failure — no
|
||||
pending or uncorrectable sectors, SMART self-assessment passes. But this is a ~2011
|
||||
consumer MLC drive that has already consumed **31% of its rated life**, grown **15** bad
|
||||
blocks, logged **187** erase failures, and taken **32** unclean power losses **with no
|
||||
power-loss protection**, in a single failure domain. etcd is the worst possible tenant for
|
||||
it: etcd fsyncs constantly, and an unclean power loss on a drive without PLP is exactly how
|
||||
a member corrupts — this drive has already logged 32 of them.
|
||||
(Non-ECC RAM is a separate, **accepted** risk — operator decision 2026-09-01, see
|
||||
`DECISIONS-LOG.md`. It is not part of this verdict.)
|
||||
- [ ] **Decide the layout.**- [ ] **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: SECOND UPSTREAM IS AN OPEN DECISION — blocks `node-baseline.yml --tags dns`.**
|
||||
Node 1 has a single IPv4 nameserver, `192.168.2.1`, which is also the LAN gateway. A single
|
||||
upstream is a known SPOF that has already bitten this project — it broke containerd image
|
||||
pulls and CoreDNS forwarding on dev.
|
||||
|
||||
The MECHANISM is now in the repo and needs no further work: the `dns`-tagged block in
|
||||
`roles/node_baseline` detects whether a node uses systemd-resolved (dev: writes
|
||||
`/etc/systemd/resolved.conf.d/99-k3s-dns.conf`, restarts resolved) or a directly-managed
|
||||
`/etc/resolv.conf` (prod's shape: writes the file, refusing if resolvconf/NetworkManager
|
||||
owns it), and configures the ordered upstream list either way. The earlier
|
||||
`fix/node-dns-resilient-upstream` branch was **dropped, not merged** — it wrote a netplan
|
||||
drop-in and ran `netplan apply`, and netplan is not installed on these Debian 13 trixie
|
||||
nodes, so it would have broken `node-baseline.yml` on every host.
|
||||
|
||||
What is still MISSING is the VALUE. `group_vars/k3s_prod/vars.yml` sets
|
||||
`node_baseline_dns_servers` to `192.168.2.1` followed by the deliberately invalid
|
||||
`BLOCKED-pending-second-upstream-decision-see-PROD-PROVISIONING-md-4`, and the role's assert
|
||||
stops the play on it before writing anything. Choosing the second upstream is a **network
|
||||
decision, not a default the role may take**: a public resolver (`8.8.8.8`, `1.1.1.1`) would
|
||||
resolve reliably but would also bypass any local zone and any filtering the gateway applies.
|
||||
Decide between — and record the choice HERE, in the same change that edits the group_vars:
|
||||
- a second on-LAN resolver on `192.168.2.x`;
|
||||
- the dev-side PiHole `192.168.1.18`, **if** prod can actually route to it (unverified —
|
||||
prod sits on the workstation side of the pfSense boundary);
|
||||
- an explicit, written-down acceptance of a public resolver as fallback-only.
|
||||
|
||||
Also decide whether node 1's current IPv6 nameservers (`2001:4860:4860::8888` / `::8844`)
|
||||
are kept: the direct-file write path replaces `/etc/resolv.conf` with exactly the listed
|
||||
servers, so keeping them means listing them explicitly.
|
||||
`node_baseline_dns_search` is `[]` (no search domain, node 1's behaviour today) until prod's
|
||||
local domain is decided.
|
||||
- [ ] 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).
|
||||
@@ -1,3 +1,13 @@
|
||||
# Runtime cache written by the cilium/verify/flux plays (fetched kubeconfig,
|
||||
# rendered Cilium values). Never commit — the kubeconfig is a credential.
|
||||
.cache/
|
||||
|
||||
# Generated node-health reports (roles/node_health, playbooks/node-health.yml).
|
||||
# Regenerated on every run and they carry drive serial numbers — never commit. Same
|
||||
# convention as conformance/_artifacts/: generated output is an artifact, not source.
|
||||
_artifacts/
|
||||
|
||||
# Galaxy collections installed by `ansible-galaxy collection install -r
|
||||
# collections/requirements.yml -p collections/`. Pinned by requirements.yml, fetched at
|
||||
# setup time — vendored bytes are not source. ansible.cfg sets collections_path.
|
||||
collections/ansible_collections/
|
||||
+88
-2
@@ -35,9 +35,10 @@ ansible-playbook playbooks/flux-bootstrap.yml # 4. hand off to bootstrap/ (SOP
|
||||
> **`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); targets k3s_servers
|
||||
> ansible-playbook playbooks/vpn.egress.gateway.yml # node-host OpenVPN split-tunnel + kill-switch; targets k3s-dev-3
|
||||
> 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
|
||||
@@ -88,6 +89,91 @@ 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.
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
[defaults]
|
||||
inventory = inventory/hosts.ini
|
||||
roles_path = roles
|
||||
# Galaxy collections live in the repo (gitignored), installed from
|
||||
# collections/requirements.yml. The devcontainer image does NOT ship them, so without
|
||||
# this every play dies at parse time on community.general.modprobe.
|
||||
collections_path = collections
|
||||
host_key_checking = False
|
||||
retry_files_enabled = False
|
||||
# SSH key the k3s nodes authorize for the `k3s` user (see inventory ansible_user).
|
||||
|
||||
@@ -4,12 +4,35 @@
|
||||
# cloud-init created this login user (tofu var login_user); it has NOPASSWD sudo.
|
||||
ansible_user: k3s
|
||||
|
||||
# --- environment group NAMES (R-ENV-02: one set of roles, scoped overrides) ---------
|
||||
# The GROUP NAMES a role uses to answer "who is MY environment's primary / MY
|
||||
# environment's servers?". Roles must never hardcode `groups['k3s_primary']`, or they
|
||||
# only ever work against dev. These two are the dev values; group_vars/k3s_prod/vars.yml
|
||||
# overrides them with k3s_prod_primary / k3s_prod, so a host in the prod group resolves
|
||||
# its own primary automatically — exactly the way k3s_api_host below is overridden there.
|
||||
#
|
||||
# These are GROUP NAMES (keys into `groups`), NOT host patterns. The play-level target
|
||||
# pattern is a separate, extra-vars-only knob (`k3s_hosts` / `k3s_primary_hosts` /
|
||||
# `k3s_secondary_hosts`, see playbooks/*.yml) because Ansible templates a play's `hosts:`
|
||||
# before it has any host context and so cannot see group_vars at all.
|
||||
k3s_primary_group: k3s_primary
|
||||
k3s_servers_group: k3s_servers
|
||||
|
||||
# Filename of the kubeconfig the controller-side roles (cilium, cluster_verify,
|
||||
# flux_bootstrap) fetch from the primary into infra/ansible/.cache/. Scoped per
|
||||
# environment so pointing those plays at prod can NEVER overwrite the dev kubeconfig
|
||||
# that infra/devcontainer/dev.sh exports as $KUBECONFIG — an operator's `kubectl get
|
||||
# nodes` silently answering from prod would be a genuinely dangerous mix-up.
|
||||
# group_vars/k3s_prod/vars.yml overrides this to prod.kubeconfig. (.cache/ is gitignored;
|
||||
# a kubeconfig is a credential.)
|
||||
cluster_kubeconfig_name: dev.kubeconfig
|
||||
|
||||
# The kube-apiserver endpoint the whole stack agrees on: the primary's REAL node IP.
|
||||
# NOTE: ansible_host is now the pfSense NAT SSH target (192.168.2.102:225x), NOT the node
|
||||
# IP — so we read the real 192.168.1.x from k3s_node_ip instead. Used for k3s --tls-san /
|
||||
# secondary --server AND for Cilium k8sServiceHost so the Ansible bootstrap and the Flux
|
||||
# HelmRelease stay in PARITY (docs/02 contract).
|
||||
k3s_api_host: "{{ hostvars[groups['k3s_primary'][0]]['k3s_node_ip'] }}"
|
||||
k3s_api_host: "{{ hostvars[groups[k3s_primary_group][0]]['k3s_node_ip'] }}"
|
||||
k3s_api_port: 6443
|
||||
|
||||
# Control-plane HA VIP floated by kube-vip (roles/kube_vip). A stable failover
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# group_vars for the `k3s_prod` group (prod k3s nodes — Phase 7 PREP ONLY)
|
||||
|
||||
Every node-host play that touches a k3s node (`playbooks/node-baseline.yml`, `k3s.yml`,
|
||||
`cilium.yml`, `kube-vip.yml`, ...) already runs with `become: true` at the play level. On **dev**
|
||||
that succeeds without a password (cloud-init's `login_user` gets NOPASSWD sudo). **Prod
|
||||
deliberately does NOT get that** — operator decision, 2026-08-31 (`DECISIONS-LOG.md`, "PHASE 7
|
||||
OPENED", finding 3; `infra/PROD-PROVISIONING.md` §5). Supply the sudo password as an
|
||||
**ansible-vault-encrypted** var here — it is encrypted, so it is safe to commit; the passphrase
|
||||
that decrypts it stays out of the repo, exactly like the existing `hypervisor` and `proxmox_hosts`
|
||||
groups.
|
||||
|
||||
## Two files (one committed-encrypted, one never committed)
|
||||
|
||||
1. **The vault passphrase** — a file **outside the repo**, referenced by `ansible.cfg`
|
||||
(`vault_password_file = ~/.config/home-k3s/ansible-vault-pass`). Skip this if you already made
|
||||
one for the `hypervisor` or `proxmox_hosts` vaults — it's the SAME passphrase file, shared by
|
||||
every vaulted group in this repo:
|
||||
```bash
|
||||
mkdir -p ~/.config/home-k3s
|
||||
install -m600 /dev/stdin ~/.config/home-k3s/ansible-vault-pass <<<'YOUR_VAULT_PASSPHRASE'
|
||||
```
|
||||
|
||||
2. **The encrypted become password** — `vault.yml` in **this directory** (committed, encrypted).
|
||||
`vault.yml.example` alongside it shows the exact shape (see it for the reasoning); copy its
|
||||
content, fill in the real password, then encrypt it in place — do not commit the `.example`
|
||||
content unencrypted as `vault.yml`:
|
||||
```bash
|
||||
cd infra/ansible
|
||||
ansible-vault create inventory/group_vars/k3s_prod/vault.yml
|
||||
# file content (single line):
|
||||
# ansible_become_password: "THE k3s USER'S SUDO PASSWORD ON k3s-prod-1"
|
||||
```
|
||||
(`ansible-vault create` uses the passphrase file above automatically — no prompt.)
|
||||
|
||||
Once `vault.yml` exists, prod plays run unattended — same as dev, just with a real sudo boundary
|
||||
behind them instead of NOPASSWD.
|
||||
|
||||
## Notes
|
||||
- `vault.yml` is skipped by the yamllint gate (`ci/yamllint.yaml` ignores `**/vault.yml`) because a
|
||||
vault blob is not parseable YAML. `vault.yml.example` is plain YAML and IS linted — it's a
|
||||
copy-paste scaffold, not a secret (same convention as
|
||||
`platform/backup/minio-credentials.sops.yaml.example`).
|
||||
- The k3s **dev** nodes keep passwordless sudo unchanged — this vault is scoped to the `k3s_prod`
|
||||
group only.
|
||||
- `ansible_become: true` for this group is set explicitly in `vars.yml` (this directory) — belt
|
||||
and braces alongside the playbooks' own `become: true`, so prod's posture is self-documenting
|
||||
and doesn't rely on inheriting anything from `group_vars/all.yml` by accident.
|
||||
- Non-secret prod vars live in `vars.yml`, not here — this README is scoped to the vault hand-off.
|
||||
`k3s_api_host` is set to a real value. `kube_vip_address`, `cluster_controller_api_server`,
|
||||
`node_baseline_longhorn_device` and `node_baseline_etcd_device` are all still-BLOCKED
|
||||
placeholders pending operator decisions (control-plane VIP and disk layout, respectively) — see
|
||||
`vars.yml`'s comments for what IS already decided (node addresses, the separate *ingress* VIP,
|
||||
which is a Flux/`cluster-vars` concern and does not live here) versus what is not.
|
||||
- This is the Ansible-bootstrap-layer secret (host credential), distinct from the platform's
|
||||
in-cluster **SOPS + age** secrets (ADR-0007) — different layer, different tool, same convention
|
||||
as `hypervisor`/`proxmox_hosts`.
|
||||
- **Targeting prod is solved** (2026-08-31): plays take `-e k3s_hosts=k3s_prod` (and, for `k3s.yml`,
|
||||
`-e k3s_primary_hosts=k3s_prod_primary -e k3s_secondary_hosts=k3s_prod_secondary`), while the
|
||||
roles resolve "my environment's primary/servers" from `k3s_primary_group` / `k3s_servers_group`
|
||||
in `vars.yml` here — no role fork, no per-run flag for that half. See
|
||||
`infra/ansible/README.md` → "Targeting prod".
|
||||
- **Do not run any prod playbook yet.** Phase 7 is prep-only: the vault above just makes the plays
|
||||
*able* to authenticate once the §7 repo blockers close and the disk-layout (§2) decision in
|
||||
`infra/PROD-PROVISIONING.md` is made. The ONE exception the operator has sanctioned is the
|
||||
packages-only baseline that unblocks the §2 drive-health read —
|
||||
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages` — which
|
||||
cannot touch the BLOCKED device placeholders because every disk task is `disks`-tagged and so is
|
||||
not selected. It still needs `vault.yml` (above) to exist first.
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
# group_vars/k3s_prod/vars.yml — NON-secret prod-scoped overrides for the [k3s_prod]
|
||||
# umbrella group (inventory/hosts.ini: [k3s_prod_primary]/[k3s_prod_secondary] under
|
||||
# [k3s_prod:children]). Ansible auto-loads group_vars/<groupname>/*.yml for every host
|
||||
# that is a (transitive) member of that group. See infra/PROD-PROVISIONING.md (T-7.1)
|
||||
# §5/§6 for the checklist this file implements, and DECISIONS-LOG.md "PHASE 7 OPENED"
|
||||
# (2026-08-31) for the operator decisions behind it.
|
||||
#
|
||||
# Pairs with vault.yml in this same directory (ansible-vault-encrypted, operator-
|
||||
# created — see README.md here) for the one secret prod needs: ansible_become_password.
|
||||
#
|
||||
# PHASE 7 IS PREP ONLY. Five vars below (kube_vip_address, cluster_controller_api_server,
|
||||
# node_baseline_longhorn_device, node_baseline_etcd_device, and the SECOND entry of
|
||||
# node_baseline_dns_servers) are deliberately BROKEN
|
||||
# PLACEHOLDERS, not real values — see their comments. Do not "fix" them with a guess;
|
||||
# follow the referenced section of PROD-PROVISIONING.md, record the decision there,
|
||||
# and update this file in the same change.
|
||||
|
||||
# --- become posture (§5) ----------------------------------------------------------
|
||||
# Prod deliberately does NOT get dev's passwordless (NOPASSWD) sudo — operator
|
||||
# decision, 2026-08-31 (DECISIONS-LOG.md "PHASE 7 OPENED", finding 3). group_vars/
|
||||
# all.yml carries no explicit `ansible_become` of its own (dev's passwordless sudo
|
||||
# means become succeeds without a password prompt, not that become is switched off),
|
||||
# and every node-host playbook already sets `become: true` at the play level
|
||||
# (node-baseline.yml, k3s.yml, cilium.yml, kube-vip.yml) — for dev that silently
|
||||
# succeeds because sudo needs no password there. Setting ansible_become explicitly
|
||||
# here is belt-and-braces: this group's posture is self-documenting and does not rely
|
||||
# on inheriting dev's assumption by accident.
|
||||
ansible_become: true
|
||||
# The password itself is ansible_become_password — ansible-vault-encrypted, in
|
||||
# vault.yml (this directory), never here in plaintext. See README.md to create it.
|
||||
|
||||
# --- environment group NAMES (R-ENV-02) -------------------------------------------
|
||||
# group_vars/all.yml defines these as the DEV group names (k3s_primary / k3s_servers);
|
||||
# here they name PROD's groups. Roles index `groups[k3s_primary_group]` /
|
||||
# `groups[k3s_servers_group]` instead of a literal, so roles/k3s_server and roles/cilium
|
||||
# resolve "my environment's primary" and "my environment's servers" correctly for prod
|
||||
# hosts with NO role fork and no per-run flag — the reuse R-ENV-02 promises.
|
||||
#
|
||||
# NOTE these are GROUP NAMES, not the play-level target pattern. Selecting which hosts a
|
||||
# play RUNS against is a separate extra-vars knob (`-e k3s_hosts=k3s_prod`, and for
|
||||
# k3s.yml `-e k3s_primary_hosts=k3s_prod_primary -e k3s_secondary_hosts=k3s_prod_secondary`),
|
||||
# because Ansible templates `hosts:` before any host — and therefore any group_vars —
|
||||
# exists. See infra/ansible/README.md "Targeting prod".
|
||||
k3s_primary_group: k3s_prod_primary
|
||||
k3s_servers_group: k3s_prod
|
||||
|
||||
# Keep the fetched prod kubeconfig in its OWN cache file. group_vars/all.yml defaults this
|
||||
# to dev.kubeconfig, which is the file infra/devcontainer/dev.sh exports as $KUBECONFIG —
|
||||
# a prod play must never clobber it (a `kubectl` that silently answers from prod is worse
|
||||
# than no kubeconfig at all). .cache/ is gitignored; a kubeconfig is a credential.
|
||||
cluster_kubeconfig_name: prod.kubeconfig
|
||||
|
||||
# --- k3s API host — safe to set now, the primary's real IP is already known -------
|
||||
# Dev's k3s_api_host (group_vars/all.yml) reads a separate k3s_node_ip hostvar because
|
||||
# dev's ansible_host is the pfSense NAT SSH target, not the node's real address. Prod
|
||||
# has NO NAT hop — ansible_host in hosts.ini IS already the node's real, routable LAN
|
||||
# address (see the KEY DIFFERENCE note there) — so point straight at it instead of
|
||||
# needing a k3s_node_ip split.
|
||||
k3s_api_host: "{{ hostvars[groups[k3s_primary_group][0]]['ansible_host'] }}"
|
||||
# cluster_controller_tls_server_name (group_vars/all.yml) derives from kube_vip_address
|
||||
# by template reference, so it needs no separate override here — it picks up the real
|
||||
# value set below automatically for prod hosts too.
|
||||
|
||||
# --- control-plane VIP + controller access — DECIDED (§4) -------------------------
|
||||
# Prod addressing, all decided by the operator 2026-08-31:
|
||||
# nodes k3s-prod-1/2/3 192.168.2.10 / .11 / .12 (hosts.ini)
|
||||
# control-plane VIP 192.168.2.9 (THIS value — kube-vip, Ansible-side)
|
||||
# ingress Gateway VIP 192.168.2.20 (Flux/cluster-vars — NOT here)
|
||||
# ingress L2 pool 192.168.2.20 - .30 (Flux/cluster-vars — NOT here)
|
||||
#
|
||||
# The control-plane VIP sits BELOW the node addresses and deliberately OUTSIDE the
|
||||
# Cilium LB pool (.20-.30), so kube-vip and Cilium LB-IPAM can never contend for the
|
||||
# same address. That separation is the point: dev learned the same shape the other way
|
||||
# round (control-plane .49, ingress pool .53-.60).
|
||||
#
|
||||
# This is the address kube-vip floats for the API and that k3s tls-san's onto its
|
||||
# serving cert; secondaries use it as their --server join target. Dev's equivalent is
|
||||
# kube_vip_address: 192.168.1.49 in group_vars/all.yml.
|
||||
#
|
||||
# HISTORY: an earlier pass here wrongly set this to 192.168.2.20 — that is the INGRESS
|
||||
# VIP, not the control-plane VIP. Corrected. Keep the two distinct.
|
||||
kube_vip_address: "192.168.2.9"
|
||||
# Derived from kube_vip_address above (not repeated), so fixing the one line above is
|
||||
# the only change needed once a real control-plane VIP is chosen. Prod has no NAT hop
|
||||
# (unlike dev's cluster_controller_api_server, the dev-container's NAT path to its VIP
|
||||
# — see group_vars/all.yml), so once real this collapses to a direct "https://<VIP>:6443".
|
||||
cluster_controller_api_server: "https://{{ kube_vip_address }}:6443"
|
||||
|
||||
# --- node_baseline data-disk devices — BLOCKED, open decision (§2) ----------------
|
||||
# roles/node_baseline defaults (node_baseline_longhorn_device: /dev/vdb,
|
||||
# node_baseline_etcd_device: /dev/vdc) assume tofu-provisioned virtio disks that DO
|
||||
# NOT EXIST on bare metal. Node 1 is a SINGLE SATA disk (/dev/sda) with no free block
|
||||
# device at all today; PROD-PROVISIONING.md §2 lists three candidate fixes (add a
|
||||
# second drive / repartition / reuse the /dev/sda5 "/srv" partition) that all wait on
|
||||
# a SMART health read of a ~15-year-old consumer SSD (operator decision, 2026-08-31).
|
||||
#
|
||||
# The overrides below are deliberately present-but-broken: leaving them UNSET would
|
||||
# let the role silently fall back to /dev/vdb//dev/vdc, which ALSO don't exist on this
|
||||
# hardware — a confusing failure that looks like a wiring bug. This value fails just
|
||||
# as hard, but says exactly why, and blocks a `find`/glob from accidentally matching
|
||||
# a real device. DO NOT replace with a guess — replace only once §2 is decided, and
|
||||
# record the chosen device paths in PROD-PROVISIONING.md §2 in the SAME change that
|
||||
# updates these two lines. All three nodes must end up with an identical layout
|
||||
# (Longhorn replica placement assumes symmetry) before any of this is used for real.
|
||||
node_baseline_longhorn_device: "/dev/BLOCKED-pending-disk-decision-see-PROD-PROVISIONING-md-2"
|
||||
node_baseline_etcd_device: "/dev/BLOCKED-pending-disk-decision-see-PROD-PROVISIONING-md-2"
|
||||
|
||||
# --- node_baseline DNS upstreams — SECOND UPSTREAM BLOCKED, open decision (§4) -----
|
||||
# roles/node_baseline defaults are DEV's list (192.168.1.18 PiHole, then 192.168.1.1
|
||||
# pfSense) — addresses on a network prod cannot even reach. Inherited verbatim they would
|
||||
# point every prod node at a PiHole that does not answer it, so they are overridden here,
|
||||
# in the same seam as kube_vip_address / k3s_api_host / cluster_kubeconfig_name above.
|
||||
# (NOT clusters/base/cluster-vars.yaml: that ConfigMap is consumed by Flux
|
||||
# postBuild.substituteFrom for platform/ and apps/ manifests, and no Ansible code reads it.)
|
||||
#
|
||||
# WHAT IS DECIDED: 192.168.2.1 — node 1's current sole nameserver, and the LAN gateway.
|
||||
# It stays FIRST, so whatever local zone it serves keeps working (the dev outage taught
|
||||
# that ordering is policy, not preference — see the role's defaults).
|
||||
#
|
||||
# WHAT IS NOT: the SECOND upstream. 192.168.2.1 is presently gateway AND only IPv4
|
||||
# resolver, and there is no second address on this LAN that is known to answer. Picking
|
||||
# one is a NETWORK decision, not a default this role may take: a public resolver
|
||||
# (8.8.8.8, 1.1.1.1) would resolve reliably but would ALSO bypass any local zone and any
|
||||
# network-level filtering the gateway applies — precisely the bypass the dev fix refused
|
||||
# to make. Candidates the operator must choose between and record in
|
||||
# infra/PROD-PROVISIONING.md §4: a second on-LAN resolver, the dev-side PiHole IF prod can
|
||||
# route to 192.168.1.18, or an explicit, written-down decision to accept a public resolver
|
||||
# as fallback-only.
|
||||
#
|
||||
# Deliberately present-but-broken, exactly like node_baseline_longhorn_device above:
|
||||
# leaving the list at one entry, or unset, would either configure the very SPOF this exists
|
||||
# to remove or silently inherit dev's PiHole. This value can never parse as an address, and
|
||||
# the `dns`-tagged assert in roles/node_baseline stops the play on the string BLOCKED
|
||||
# before a single file is written. DO NOT replace it with a guess — replace it only once
|
||||
# §4 is decided, and record the chosen resolver there in the SAME change.
|
||||
#
|
||||
# Note the two Google IPv6 servers currently in node 1's /etc/resolv.conf
|
||||
# (2001:4860:4860::8888/::8844) are NOT carried over: the direct-file write path replaces
|
||||
# the file with exactly this list, so if they are to be kept they must be listed here
|
||||
# explicitly — as part of that same recorded decision.
|
||||
node_baseline_dns_servers:
|
||||
# DECIDED (operator, 2026-09-01): prod's resolvers are PUBLIC and deliberately differ
|
||||
# from dev's. Dev is PiHole-first (192.168.1.18) because dev needs the local jackson.lan
|
||||
# zone AND split-horizon *.home.keithjackson.dev. Prod has no local resolver on its LAN,
|
||||
# so it takes two INDEPENDENT public upstreams — which resolves the single-upstream SPOF
|
||||
# (a single upstream broke containerd image pulls and CoreDNS forwarding on dev).
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
# SCOPE — this file sets NODE resolution only: containerd image pulls, and the upstream
|
||||
# CoreDNS forwards `.` to. Three different resolvers answer *.home.keithjackson.dev, and
|
||||
# only ONE of them is configured here:
|
||||
#
|
||||
# pods (in-cluster) -> CoreDNS -> platform/coredns/coredns-custom.yaml, whose `template`
|
||||
# plugin answers the WHOLE home.keithjackson.dev zone with ${GATEWAY_VIP}. Entirely
|
||||
# self-contained in the cluster; PiHole is NOT in this path. Prod is already correct
|
||||
# (GATEWAY_VIP = 192.168.2.20 in clusters/prod).
|
||||
# LAN clients -> PiHole. Not a cluster concern.
|
||||
# nodes -> THIS FILE. Public resolvers, so the zone resolves PUBLICLY.
|
||||
#
|
||||
# THE ONE THING THAT BREAKS: the k3s API SERVER is a node process and carries
|
||||
# `oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab`
|
||||
# (roles/k3s_server/tasks/main.yml). It reaches the issuer through the NODE resolver, so on
|
||||
# prod it would get the PUBLIC record instead of the prod Gateway VIP — breaking kubectl
|
||||
# OIDC SSO. Dev avoids this because its node resolver is PiHole, which answers the zone
|
||||
# with the dev Gateway VIP.
|
||||
#
|
||||
# NOT blocking today (prod has no cluster and no Keycloak yet). Options, in
|
||||
# PROD-PROVISIONING.md §4: a narrow Ansible-managed /etc/hosts entry pinning
|
||||
# keycloak.home.keithjackson.dev -> 192.168.2.20 on prod nodes; or a local resolver on the
|
||||
# prod LAN serving the zone; or run prod on the cert kubeconfig and defer OIDC.
|
||||
node_baseline_dns_search: []
|
||||
@@ -0,0 +1,7 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
33643331386138666338353162353562613737343265376639646262663931663239333735366163
|
||||
3961623239313038313966326339336464383763616266310a313766653231636636393030306635
|
||||
37653764656235363738363535386539613938393839623462316134383631623464343930383133
|
||||
6433386532653564630a383036633266353965383565666639363733393531643162366265366537
|
||||
30313265343035653961666331663534313564333939383038306338666535316639333136363036
|
||||
6532313164613836663862303561646362303130316136306361
|
||||
@@ -0,0 +1,25 @@
|
||||
# =============================================================================
|
||||
# TEMPLATE — NOT ENCRYPTED, NOT LOADED (a plain `.example` file; Ansible only reads
|
||||
# `vault.yml` in this directory, never `vault.yml.example`). Reference scaffold +
|
||||
# OPERATOR HAND-OFF, same convention as platform/backup/minio-credentials.sops.yaml.example.
|
||||
#
|
||||
# WHAT THIS IS: the sudo (become) password for the `k3s` user on prod k3s nodes.
|
||||
# Prod deliberately does NOT use passwordless (NOPASSWD) sudo the way dev does —
|
||||
# operator decision, 2026-08-31 (DECISIONS-LOG.md "PHASE 7 OPENED", finding 3;
|
||||
# infra/PROD-PROVISIONING.md §5). Every play that becomes root on a k3s_prod host
|
||||
# (node-baseline.yml, k3s.yml, cilium.yml, kube-vip.yml, ...) needs this to escalate.
|
||||
#
|
||||
# HAND-OFF STEPS (operator):
|
||||
# (a) Create the vault passphrase file ONCE, outside the repo, if you don't already
|
||||
# have one from the `hypervisor`/`proxmox_hosts` vaults (it's the same file,
|
||||
# shared by every vaulted group in this repo):
|
||||
# mkdir -p ~/.config/home-k3s
|
||||
# install -m600 /dev/stdin ~/.config/home-k3s/ansible-vault-pass <<<'YOUR_VAULT_PASSPHRASE'
|
||||
# (b) Encrypt the REAL content into inventory/group_vars/k3s_prod/vault.yml — never
|
||||
# commit this .example file's content unencrypted as vault.yml itself:
|
||||
# cd infra/ansible
|
||||
# ansible-vault create inventory/group_vars/k3s_prod/vault.yml
|
||||
# # paste the single line below with the REAL password, save, exit
|
||||
# (c) Verify: ansible-vault view inventory/group_vars/k3s_prod/vault.yml
|
||||
# =============================================================================
|
||||
ansible_become_password: "REPLACE-ME"
|
||||
@@ -45,3 +45,57 @@ k3s-dev-3
|
||||
# `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). Node 1
|
||||
# (`k3s-prod-1`, HP EliteDesk 800 G5 Mini) is on the bench and SSH-reachable; nodes 2
|
||||
# and 3 have not arrived. This section is inventory/group_vars scaffolding only —
|
||||
# nothing has been run against node 1, and the operator has decided the CLUSTER stays
|
||||
# uninitialized (no k3s install, no --cluster-init) until all three nodes are present
|
||||
# (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]
|
||||
# Addresses for nodes 2/3 ARE decided (operator, 2026-08-31 — infra/PROD-PROVISIONING.md
|
||||
# §0), but the nodes themselves are NOT yet racked/imaged/reachable. Left commented so
|
||||
# no play can accidentally target a host that isn't there; uncomment each line only once
|
||||
# that node is actually on the bench and answering SSH.
|
||||
# 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
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
# Install Cilium as the CNI (ADR-0002). The cluster is NotReady until this runs.
|
||||
# Executed from the controller against a fetched kubeconfig (role handles delegation).
|
||||
#
|
||||
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
|
||||
# ansible-playbook playbooks/cilium.yml -e k3s_hosts=k3s_prod
|
||||
# The role's "fetch the kubeconfig from the primary" delegation follows the
|
||||
# k3s_primary_group group_var, so it picks the prod primary automatically.
|
||||
- name: Install Cilium CNI
|
||||
hosts: k3s_servers
|
||||
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
|
||||
become: true
|
||||
gather_facts: false
|
||||
roles:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
---
|
||||
# FINAL step: hand off to Flux + SOPS (T-1.4). Calls into bootstrap/ (authored by
|
||||
# the Security + GitOps Engineers) — this play does not author that subtree.
|
||||
#
|
||||
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
|
||||
# ansible-playbook playbooks/flux-bootstrap.yml -e k3s_hosts=k3s_prod
|
||||
# NOTE the Flux PATH is a separate concern: roles/flux_bootstrap shells out to
|
||||
# bootstrap/flux/bootstrap.sh, which bootstraps --path=clusters/dev. Prod needs
|
||||
# --path=clusters/prod, which is a bootstrap/ change, NOT a target-group change
|
||||
# (infra/PROD-PROVISIONING.md §7 tracks it).
|
||||
- name: Flux + SOPS bootstrap hand-off
|
||||
hosts: k3s_servers
|
||||
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
|
||||
become: false
|
||||
gather_facts: false
|
||||
roles:
|
||||
|
||||
@@ -2,9 +2,20 @@
|
||||
# Install the HA k3s control plane (embedded etcd, 3 servers) — ADR-0001.
|
||||
# Primary founds the cluster (--cluster-init); secondaries join it in a second
|
||||
# play so the primary's node-token fact is available to them.
|
||||
#
|
||||
# TARGET (R-ENV-02): this is the one playbook that needs TWO patterns, because the
|
||||
# primary/secondary split is the whole point of the file. Both default to dev:
|
||||
# ansible-playbook playbooks/k3s.yml # dev, unchanged
|
||||
# ansible-playbook playbooks/k3s.yml \
|
||||
# -e k3s_primary_hosts=k3s_prod_primary \
|
||||
# -e k3s_secondary_hosts=k3s_prod_secondary # prod
|
||||
# The role's OWN "who is the primary" lookups (join token delegate_to, tls-san list)
|
||||
# do NOT use these — they follow the k3s_primary_group / k3s_servers_group group_vars,
|
||||
# which prod sets once in group_vars/k3s_prod/vars.yml. These two vars only choose which
|
||||
# hosts the plays RUN on, which Ansible resolves before any group_vars exist.
|
||||
|
||||
- name: k3s primary (cluster-init)
|
||||
hosts: k3s_primary
|
||||
hosts: "{{ k3s_primary_hosts | default('k3s_primary') }}"
|
||||
become: true
|
||||
gather_facts: true
|
||||
roles:
|
||||
@@ -16,7 +27,7 @@
|
||||
# an updated tls-san set) triggers the Restart k3s handler: restarting both
|
||||
# secondaries at once would drop etcd below quorum. One-at-a-time keeps 2/3 up.
|
||||
- name: k3s secondaries (join)
|
||||
hosts: k3s_secondary
|
||||
hosts: "{{ k3s_secondary_hosts | default('k3s_secondary') }}"
|
||||
become: true
|
||||
serial: 1
|
||||
gather_facts: true
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
---
|
||||
# Deploy kube-vip as the control-plane HA VIP (192.168.1.49) for the kube-API.
|
||||
# Deploy kube-vip as the control-plane HA VIP (192.168.1.49 on dev) for the kube-API.
|
||||
# Control-plane ONLY (svc_enable=false) — Cilium owns Service LoadBalancing.
|
||||
# Writes the manifest into each server's k3s auto-deploy dir; k3s applies it.
|
||||
#
|
||||
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
|
||||
# ansible-playbook playbooks/kube-vip.yml -e k3s_hosts=k3s_prod
|
||||
# (the VIP itself comes from kube_vip_address, already scoped per environment in
|
||||
# group_vars — dev 192.168.1.49, prod 192.168.2.9).
|
||||
- name: Deploy kube-vip control-plane VIP
|
||||
hosts: k3s_servers
|
||||
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
|
||||
become: true
|
||||
gather_facts: true
|
||||
roles:
|
||||
|
||||
@@ -2,8 +2,21 @@
|
||||
# Node baseline — first-boot floor for k3s/Cilium/Longhorn, moved from cloud-init into
|
||||
# Ansible so Proxmox provisioning stays API-only (no SSH/SFTP to the hypervisor). Runs
|
||||
# FIRST in site.yml, before k3s. Idempotent; safe to re-run.
|
||||
#
|
||||
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
|
||||
# ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod
|
||||
# See README.md "Targeting prod". The pattern is the one node-health.yml established.
|
||||
#
|
||||
# TAGS — this role is the only way `smartmontools` (and the rest of the package floor)
|
||||
# reaches a node, but its data-disk tasks need block devices that prod has NOT decided
|
||||
# yet (infra/PROD-PROVISIONING.md §2). Run JUST the package floor with:
|
||||
# ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages
|
||||
# `--tags packages` runs the apt task ONLY; every disk task carries the `disks` tag and
|
||||
# is therefore skipped, so the BLOCKED placeholder devices can never be touched. A full
|
||||
# (untagged) run against a node whose device vars are still placeholders fails fast on an
|
||||
# explicit assert instead of handing a bogus path to mkfs. See roles/node_baseline.
|
||||
- name: Baseline the k3s nodes (packages, kernel modules, Longhorn disk, swap-off)
|
||||
hosts: k3s_servers
|
||||
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
|
||||
become: true
|
||||
gather_facts: true
|
||||
roles:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# Node hardware health — READ-ONLY inspection of a node's disks, memory, CPU, thermals,
|
||||
# network and pressure-stall state. Installs nothing, changes nothing, safe to run at any
|
||||
# time against dev or prod (see roles/node_health for the full contract).
|
||||
#
|
||||
# ./dev.sh run bash -c 'cd /work/infra/ansible && \
|
||||
# ansible-playbook playbooks/node-health.yml -e node_health_hosts=k3s_prod'
|
||||
#
|
||||
# TARGET GROUP IS A VARIABLE. This play established that pattern; the rest of the node
|
||||
# playbooks (node-baseline.yml, k3s.yml, cilium.yml, kube-vip.yml, verify.yml,
|
||||
# flux-bootstrap.yml) now follow it and take `k3s_hosts` / `k3s_primary_hosts` /
|
||||
# `k3s_secondary_hosts` instead of the literal dev groups they used to hardcode — see
|
||||
# infra/ansible/README.md "Targeting prod". This one keeps its OWN var name because it is
|
||||
# a standalone diagnostic you point anywhere, not a step in the bring-up chain: it must
|
||||
# stay independently targetable (`-e node_health_hosts=<group>`) even while a bring-up run
|
||||
# is scoped elsewhere. Run it against prod with `-e node_health_hosts=k3s_prod`, or a
|
||||
# single box with `--limit`.
|
||||
#
|
||||
# BECOME: left at the play level exactly as the inventory sets it. The role itself pins
|
||||
# `ansible_become: false` on every unprivileged task and probes escalation once before
|
||||
# attempting the only root-only check (SMART), so this play behaves correctly on dev
|
||||
# (NOPASSWD sudo) and on prod (vaulted become password, or none at all) without any
|
||||
# per-environment flags. See roles/node_health/tasks/main.yml for why that has to be a
|
||||
# variable override rather than `become: false`.
|
||||
- name: Inspect node hardware health (read-only)
|
||||
hosts: "{{ node_health_hosts | default('k3s_servers') }}"
|
||||
# The role's own collector supersedes ansible.builtin.setup for this purpose (setup
|
||||
# exposes no thermal zones, no EDAC, no PSI, no per-disk error counters), so skip the
|
||||
# fact gathering round trip entirely.
|
||||
gather_facts: false
|
||||
roles:
|
||||
- node_health
|
||||
@@ -2,6 +2,18 @@
|
||||
# Full dev/test cluster bring-up, in order (docs/02 sequencing; run AFTER
|
||||
# `tofu apply` has created and cloud-init has baselined the VMs):
|
||||
# k3s HA install -> Cilium CNI -> verify readiness -> Flux + SOPS hand-off.
|
||||
#
|
||||
# TARGET (R-ENV-02): every imported play takes its host pattern from a var that
|
||||
# defaults to the dev group, so `ansible-playbook playbooks/site.yml` with no extra
|
||||
# vars is byte-for-byte the dev run it always was. Extra vars are global and reach
|
||||
# imported playbooks, so the whole chain retargets with three flags:
|
||||
# ansible-playbook playbooks/site.yml \
|
||||
# -e k3s_hosts=k3s_prod \
|
||||
# -e k3s_primary_hosts=k3s_prod_primary \
|
||||
# -e k3s_secondary_hosts=k3s_prod_secondary
|
||||
# DO NOT run that yet — prod is blocked on the disk-layout decision
|
||||
# (infra/PROD-PROVISIONING.md §2) and the §7 repo blockers, and the operator has ruled
|
||||
# the prod cluster stays uninitialized until all three nodes are present (§8).
|
||||
- name: Import - node baseline (packages, kernel modules, Longhorn disk, swap-off)
|
||||
ansible.builtin.import_playbook: node-baseline.yml
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
---
|
||||
# Verify Phase-1 exit gate #1: 3 nodes Ready, Cilium/Hubble healthy, no kube-proxy.
|
||||
#
|
||||
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
|
||||
# ansible-playbook playbooks/verify.yml -e k3s_hosts=k3s_prod
|
||||
- name: Verify cluster readiness
|
||||
hosts: k3s_servers
|
||||
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
|
||||
become: false
|
||||
gather_facts: false
|
||||
roles:
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
# (ADR-0015). Host infra — the split-tunnel OpenVPN service + policy routing + scoped
|
||||
# nftables kill-switch. The CiliumEgressGatewayPolicy that steers vpn-class pods to this
|
||||
# node's tun0 is authored separately as GitOps (platform/), against the same contract:
|
||||
# * inventory group `vpn_egress_gateway` (this play's target)
|
||||
# * inventory group `vpn_egress_gateway` (this play's default target)
|
||||
# * k8s node label homelab.keithjackson.dev/egress-gateway=true (set by the role)
|
||||
# * host tunnel iface tun0 (split-tunnel; never the node default route)
|
||||
#
|
||||
# Secrets come from Ansible vault (inventory/group_vars/vpn_egress_gateway/vault.yml):
|
||||
# vault_vpn_egress_gateway_pia_username, vault_vpn_egress_gateway_pia_password, vault_vpn_egress_gateway_pia_ovpn
|
||||
# Run: ansible-playbook playbooks/vpn.egress.gateway.yml
|
||||
#
|
||||
# TARGET (R-ENV-02): the group is a var, defaulting to dev's `vpn_egress_gateway`. Prod
|
||||
# has NOT decided whether it runs an egress gateway at all, nor on which node
|
||||
# (infra/PROD-PROVISIONING.md §6, last box) — so there is deliberately no prod egress
|
||||
# group in the inventory yet. When there is, point this at it with
|
||||
# -e vpn_egress_gateway_hosts=<that group>
|
||||
# and it needs its OWN vaulted PIA credentials group_vars; this play's vault is scoped to
|
||||
# the dev group.
|
||||
- name: Configure the node-level VPN egress gateway
|
||||
hosts: vpn_egress_gateway
|
||||
hosts: "{{ vpn_egress_gateway_hosts | default('vpn_egress_gateway') }}"
|
||||
become: true
|
||||
gather_facts: true # package module + service facts need os facts
|
||||
roles:
|
||||
|
||||
@@ -19,8 +19,10 @@ gateway_api_crd_urls:
|
||||
# TLSRoute (experimental) is required by Cilium's Gateway API support.
|
||||
- "https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/{{ gateway_api_version }}/config/crd/experimental/gateway.networking.k8s.io_tlsroutes.yaml"
|
||||
|
||||
# Local kubeconfig fetched from the primary. *.kubeconfig is gitignored.
|
||||
cilium_kubeconfig: "{{ playbook_dir }}/../.cache/dev.kubeconfig"
|
||||
# Local kubeconfig fetched from the primary. *.kubeconfig is gitignored. The FILENAME
|
||||
# comes from cluster_kubeconfig_name (group_vars) so a prod run cannot overwrite the dev
|
||||
# kubeconfig the dev container exports as $KUBECONFIG. R-ENV-02.
|
||||
cilium_kubeconfig: "{{ playbook_dir }}/../.cache/{{ cluster_kubeconfig_name | default('dev.kubeconfig') }}"
|
||||
cilium_values_path: "{{ playbook_dir }}/../.cache/cilium-values.yaml"
|
||||
|
||||
# dev: single operator replica to save RAM (PHASE1-SCOPE RAM ceiling). NOT a
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
# The cluster is NotReady until this completes — expected (PHASE1-SCOPE risks).
|
||||
# run_once: this whole role acts on the cluster once, not per host.
|
||||
|
||||
# k3s_primary_group (group_vars) names THIS environment's primary group — `k3s_primary`
|
||||
# on dev, `k3s_prod_primary` on prod — so the role fetches the right cluster's kubeconfig
|
||||
# without being forked per environment (R-ENV-02).
|
||||
- name: Fetch the k3s kubeconfig from the primary
|
||||
ansible.builtin.slurp:
|
||||
src: /etc/rancher/k3s/k3s.yaml
|
||||
register: k3s_kubeconfig_raw
|
||||
delegate_to: "{{ groups['k3s_primary'][0] }}"
|
||||
delegate_to: "{{ groups[k3s_primary_group][0] }}"
|
||||
run_once: true
|
||||
|
||||
- name: Ensure the local cache dir exists
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
# cluster_verify role defaults.
|
||||
cluster_verify_kubeconfig: "{{ playbook_dir }}/../.cache/dev.kubeconfig"
|
||||
# Filename comes from cluster_kubeconfig_name (group_vars) so dev and prod never share
|
||||
# one cache file — dev.kubeconfig for dev, prod.kubeconfig for prod. R-ENV-02.
|
||||
cluster_verify_kubeconfig: "{{ playbook_dir }}/../.cache/{{ cluster_kubeconfig_name | default('dev.kubeconfig') }}"
|
||||
cluster_verify_node_ready_timeout: "300s"
|
||||
@@ -7,7 +7,8 @@
|
||||
# args below are ASSUMPTIONS the PM must reconcile with those deliverables — see
|
||||
# roles/flux_bootstrap/tasks/main.yml and infra/ansible/README.md "Interface".
|
||||
|
||||
flux_bootstrap_kubeconfig: "{{ playbook_dir }}/../.cache/dev.kubeconfig"
|
||||
# Filename from cluster_kubeconfig_name (group_vars): dev.kubeconfig / prod.kubeconfig.
|
||||
flux_bootstrap_kubeconfig: "{{ playbook_dir }}/../.cache/{{ cluster_kubeconfig_name | default('dev.kubeconfig') }}"
|
||||
|
||||
# Repo root, relative to playbooks/ (infra/ansible/playbooks -> ../../..).
|
||||
flux_bootstrap_repo_root: "{{ playbook_dir }}/../../.."
|
||||
|
||||
@@ -26,7 +26,11 @@ k3s_server_mode: "secondary"
|
||||
# the VIP), and 127.0.0.1 (local kubeconfig / health probes). k3s regenerates the
|
||||
# serving cert on restart when this set changes. Deduplicated (the primary IP is
|
||||
# also k3s_api_host).
|
||||
#
|
||||
# k3s_servers_group — NOT a literal group name — is what makes this role environment-
|
||||
# agnostic (R-ENV-02): group_vars/all.yml sets it to `k3s_servers` (dev) and
|
||||
# group_vars/k3s_prod/vars.yml to `k3s_prod`, so each environment SANs its own servers.
|
||||
k3s_tls_sans: >-
|
||||
{{ ([k3s_api_host, kube_vip_address, '127.0.0.1']
|
||||
+ (groups['k3s_servers'] | map('extract', hostvars, 'k3s_node_ip') | list))
|
||||
+ (groups[k3s_servers_group] | map('extract', hostvars, 'k3s_node_ip') | list))
|
||||
| unique }}
|
||||
@@ -121,19 +121,23 @@
|
||||
# contained — delegate_to still reaches the primary even when it isn't a play
|
||||
# target, so the join no longer depends on a fact an earlier play had to set.
|
||||
# T-1.5 finding F2 (see DECISIONS-LOG.md).
|
||||
#
|
||||
# "The primary" is resolved through k3s_primary_group (group_vars: `k3s_primary` for
|
||||
# dev, `k3s_prod_primary` for prod), never a literal group name — so a prod secondary
|
||||
# delegates to the PROD primary using this same role, unchanged (R-ENV-02).
|
||||
- name: Fetch the k3s node-token directly from the primary (secondary)
|
||||
ansible.builtin.slurp:
|
||||
src: /var/lib/rancher/k3s/server/token
|
||||
delegate_to: "{{ groups['k3s_primary'][0] }}"
|
||||
delegate_to: "{{ groups[k3s_primary_group][0] }}"
|
||||
register: k3s_primary_token_raw
|
||||
when:
|
||||
- k3s_server_mode == 'secondary'
|
||||
- hostvars[groups['k3s_primary'][0]].k3s_node_token is not defined
|
||||
- hostvars[groups[k3s_primary_group][0]].k3s_node_token is not defined
|
||||
|
||||
- name: Resolve the join token (secondary)
|
||||
ansible.builtin.set_fact:
|
||||
k3s_join_token: >-
|
||||
{{ hostvars[groups['k3s_primary'][0]].k3s_node_token
|
||||
{{ hostvars[groups[k3s_primary_group][0]].k3s_node_token
|
||||
| default(k3s_primary_token_raw.content | b64decode | trim, true) }}
|
||||
when: k3s_server_mode == 'secondary'
|
||||
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
# node_baseline — first-boot floor for k3s/Cilium/Longhorn, moved out of cloud-init
|
||||
# (Proxmox provisioning is API-only). Runs before k3s (site.yml). Idempotent.
|
||||
node_baseline_packages:
|
||||
# VM-only in practice (binds the virtio-serial channel Proxmox attaches) but harmless to install
|
||||
# unconditionally: the task below only *starts* it when that channel exists, so on bare metal
|
||||
# (prod) it installs and stays a dormant, disabled unit. Kept in the shared list rather than
|
||||
# split per-platform so dev and prod install from the exact same package set (R-ENV-02 reuse).
|
||||
- qemu-guest-agent
|
||||
- open-iscsi
|
||||
- nfs-common
|
||||
- curl
|
||||
- cryptsetup # Longhorn >=1.12 RequiredPackages check needs the cryptsetup userspace tool (dm_crypt module alone isn't enough)
|
||||
# Drive health (smartctl) is a PERMANENT part of a bare-metal node's baseline, not a one-off
|
||||
# manual install: prod runs on real disks that can fail/degrade (see infra/PROD-PROVISIONING.md
|
||||
# §2/§5 — a ~15-year-old consumer SSD on node 1), where dev's virtio disks never needed it.
|
||||
# Installed here (not hand-run with apt-get) so it's declared, idempotent, and present on every
|
||||
# node from first baseline — package installation is the role's job, never a manual step
|
||||
# (operator ruling, 2026-08-31; same click-ops-never principle as the rest of the repo).
|
||||
- smartmontools
|
||||
node_baseline_kernel_modules:
|
||||
- overlay
|
||||
- br_netfilter
|
||||
@@ -23,3 +34,40 @@ node_baseline_longhorn_fs_label: longhorn
|
||||
node_baseline_etcd_device: /dev/vdc
|
||||
node_baseline_etcd_mount: /var/lib/rancher/k3s/server/db
|
||||
node_baseline_etcd_fs_label: k3s-etcd
|
||||
|
||||
# --- DNS resilience (R-DNS-01 / R-DNS-03) -------------------------------------------
|
||||
# These are the DEV values. Prod overrides them in inventory/group_vars/k3s_prod/vars.yml
|
||||
# (the same seam kube_vip_address / k3s_api_host / cluster_kubeconfig_name use) — NOT in
|
||||
# clusters/base/cluster-vars.yaml, which is a Flux postBuild ConfigMap that no Ansible
|
||||
# code reads.
|
||||
#
|
||||
# WHY: the nodes shipped with ONE upstream resolver. PiHole answers the local jackson.lan
|
||||
# zone instantly but its external recursion flaps (measured 0/25 for registry-1.docker.io /
|
||||
# quay.io / ghcr.io while gitea.jackson.lan resolved 5/5), and one upstream is a hard SPOF:
|
||||
# it broke containerd image pulls ("lookup registry-1.docker.io: Try again") AND CoreDNS,
|
||||
# which forwards to /etc/resolv.conf and flooded its log with i/o timeouts to 192.168.1.18.
|
||||
#
|
||||
# ORDER IS THE POLICY. This is a RESILIENCE fix, not a bypass: the first entry stays PiHole,
|
||||
# so *.jackson.lan and network-wide ad-blocking keep working exactly as before, and the
|
||||
# second is only ever consulted when the first fails. Both write paths preserve that order
|
||||
# (systemd-resolved tries the servers of one scope in order; glibc walks `nameserver` lines
|
||||
# in order). Never put a public resolver first, and never enable `rotate` — either would
|
||||
# silently take the local zone and the ad-block policy out of the path.
|
||||
#
|
||||
# Keep in parity with infra/tofu (var.dns_server, var.network_gateway), which sets the same
|
||||
# two servers via cloud-init so a freshly rebuilt dev node boots with this list already.
|
||||
node_baseline_dns_servers:
|
||||
- 192.168.1.18 # PiHole — local jackson.lan zone + ad-blocking. PRIMARY, keep first.
|
||||
- 192.168.1.1 # pfSense gateway — reliable external recursion. FALLBACK only.
|
||||
# Search domain(s). An EMPTY list means "configure no search domain" and is a legitimate
|
||||
# value (prod uses it — its local domain is not a decided thing), not a missing one.
|
||||
node_baseline_dns_search:
|
||||
- jackson.lan
|
||||
# resolv.conf `options` for the DIRECT-file mechanism only (systemd-resolved does its own
|
||||
# per-server failover timing and ignores these). glibc's defaults are timeout:5 attempts:2,
|
||||
# i.e. up to 20s of stalled lookups before it reaches the second server — long enough for
|
||||
# containerd to give up on a pull. These shorten the failover, and deliberately do NOT
|
||||
# include `rotate`, which would round-robin the servers and break PiHole-first ordering.
|
||||
node_baseline_dns_options:
|
||||
- timeout:2
|
||||
- attempts:2
|
||||
@@ -2,3 +2,12 @@
|
||||
- name: Reload sysctl
|
||||
ansible.builtin.command: sysctl --system
|
||||
changed_when: true
|
||||
|
||||
# Restarting systemd-resolved re-reads /etc/systemd/resolved.conf.d/ and re-points the
|
||||
# 127.0.0.53 stub at the new upstream list. It does NOT touch link addressing or routes,
|
||||
# so the SSH/NAT session Ansible is running over is unaffected. Only fires when the
|
||||
# `dns`-tagged drop-in above actually changed, so a re-run of the role is a no-op.
|
||||
- name: Restart systemd-resolved
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
@@ -1,10 +1,25 @@
|
||||
---
|
||||
# TAGS — every task here carries one of `packages`, `system`, `disks` or `dns`. An untagged
|
||||
# run is unchanged (all four run, in this order). The split exists because the PACKAGE floor
|
||||
# and the DATA-DISK work have different readiness: packages are safe on any node from
|
||||
# first contact, while the disk tasks need block devices that a new environment may not
|
||||
# have decided yet (prod: infra/PROD-PROVISIONING.md §2, whose decision is itself blocked
|
||||
# on a SMART read that needs `smartmontools` — which ships in node_baseline_packages).
|
||||
# `--tags packages` breaks that circle: it installs the package floor and NOTHING else.
|
||||
# ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages
|
||||
# Skipping is the guard: with `--tags packages` no `disks`-tagged task is selected, so a
|
||||
# BLOCKED-pending-disk-decision device path is never even templated, let alone handed to
|
||||
# mkfs. A FULL run against such a node stops at the assert below instead. `dns` (the block
|
||||
# at the bottom of this file) works the same way and guards its own BLOCKED placeholder:
|
||||
# ansible-playbook playbooks/node-baseline.yml --tags dns
|
||||
|
||||
- name: Install baseline packages (guest agent, Longhorn/iscsi/nfs prereqs)
|
||||
ansible.builtin.apt:
|
||||
name: "{{ node_baseline_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
tags: [packages]
|
||||
|
||||
- name: Persist kernel modules for k3s/Cilium/Longhorn
|
||||
ansible.builtin.copy:
|
||||
@@ -13,12 +28,14 @@
|
||||
group: root
|
||||
mode: "0644"
|
||||
content: "{{ node_baseline_kernel_modules | join('\n') }}\n"
|
||||
tags: [system]
|
||||
|
||||
- name: Load kernel modules now
|
||||
community.general.modprobe:
|
||||
name: "{{ item }}"
|
||||
state: present
|
||||
loop: "{{ node_baseline_kernel_modules }}"
|
||||
tags: [system]
|
||||
|
||||
- name: Install sysctl tuning for k3s/Cilium
|
||||
ansible.builtin.copy:
|
||||
@@ -34,12 +51,14 @@
|
||||
fs.inotify.max_user_instances = 8192
|
||||
fs.inotify.max_user_watches = 524288
|
||||
notify: Reload sysctl
|
||||
tags: [system]
|
||||
|
||||
- name: Enable and start iscsid (Longhorn prerequisite)
|
||||
ansible.builtin.systemd:
|
||||
name: iscsid
|
||||
enabled: true
|
||||
state: started
|
||||
tags: [system]
|
||||
|
||||
# qemu-guest-agent binds to the virtio-serial channel /dev/virtio-ports/org.qemu.guest_agent.0,
|
||||
# which Proxmox attaches only when the VM's `agent` option is enabled. Our tofu keeps agent
|
||||
@@ -51,18 +70,44 @@
|
||||
ansible.builtin.stat:
|
||||
path: /dev/virtio-ports/org.qemu.guest_agent.0
|
||||
register: node_baseline_qga_channel
|
||||
tags: [system]
|
||||
|
||||
- name: Enable qemu-guest-agent (start only when its virtio channel is present)
|
||||
ansible.builtin.systemd:
|
||||
name: qemu-guest-agent
|
||||
enabled: true
|
||||
state: "{{ 'started' if node_baseline_qga_channel.stat.exists else 'stopped' }}"
|
||||
tags: [system]
|
||||
|
||||
# Fail LOUD and EARLY rather than at mkfs time when an environment has not yet decided
|
||||
# its data-disk layout. group_vars/k3s_prod/vars.yml deliberately carries
|
||||
# `/dev/BLOCKED-pending-disk-decision-see-PROD-PROVISIONING-md-2` for both devices (see
|
||||
# its comments): leaving them unset would fall back to dev's virtio /dev/vdb|/dev/vdc,
|
||||
# which do not exist on bare metal either — a confusing failure that looks like a wiring
|
||||
# bug. This assert names the real blocker instead. It is a no-op on dev (/dev/vdb,
|
||||
# /dev/vdc) and is `disks`-tagged, so `--tags packages` skips it along with the disk work.
|
||||
- name: Assert this environment's data-disk devices have actually been decided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'BLOCKED' not in node_baseline_longhorn_device"
|
||||
- "'BLOCKED' not in node_baseline_etcd_device"
|
||||
fail_msg: >-
|
||||
node_baseline_longhorn_device / node_baseline_etcd_device are still the BLOCKED
|
||||
placeholders for this host's environment
|
||||
(longhorn={{ node_baseline_longhorn_device }}, etcd={{ node_baseline_etcd_device }}).
|
||||
The disk layout is an OPEN operator decision — see infra/PROD-PROVISIONING.md §2 —
|
||||
and must NOT be guessed. To install ONLY the package floor (e.g. smartmontools, so
|
||||
the SMART read that decision waits on can be taken) re-run this play with
|
||||
`--tags packages`, which skips every disk task including this assert.
|
||||
quiet: true
|
||||
tags: [disks]
|
||||
|
||||
- name: Create ext4 filesystem on the Longhorn data disk (data-safe; skips if present)
|
||||
community.general.filesystem:
|
||||
fstype: ext4
|
||||
dev: "{{ node_baseline_longhorn_device }}"
|
||||
opts: "-L {{ node_baseline_longhorn_fs_label }}"
|
||||
tags: [disks]
|
||||
|
||||
- name: Mount the Longhorn data disk
|
||||
ansible.posix.mount:
|
||||
@@ -71,6 +116,7 @@
|
||||
fstype: ext4
|
||||
opts: "defaults,nofail,x-systemd.device-timeout=5s"
|
||||
state: mounted
|
||||
tags: [disks]
|
||||
|
||||
# Dedicated SSD etcd disk (docs/plans/ssd-etcd-isolation.md). Formats /dev/vdc and
|
||||
# mounts it at the k3s etcd data dir BEFORE k3s starts, so a rebuilt node comes up with
|
||||
@@ -84,6 +130,7 @@
|
||||
fstype: ext4
|
||||
dev: "{{ node_baseline_etcd_device }}"
|
||||
opts: "-L {{ node_baseline_etcd_fs_label }}"
|
||||
tags: [disks]
|
||||
|
||||
- name: Mount the dedicated etcd SSD disk at the k3s etcd data dir
|
||||
ansible.posix.mount:
|
||||
@@ -96,13 +143,238 @@
|
||||
dump: "0"
|
||||
passno: "2"
|
||||
state: mounted
|
||||
tags: [disks]
|
||||
|
||||
- name: Disable swap now
|
||||
ansible.builtin.command: swapoff -a
|
||||
changed_when: false
|
||||
tags: [system]
|
||||
|
||||
- name: Neutralize swap entries in fstab (kubelet requires swap off)
|
||||
ansible.builtin.replace:
|
||||
path: /etc/fstab
|
||||
regexp: '^([^#].*\sswap\s.*)$'
|
||||
replace: '# \1 # disabled for k3s/kubelet'
|
||||
tags: [system]
|
||||
|
||||
# =====================================================================================
|
||||
# DNS RESILIENCE — tag `dns` (R-DNS-01 / R-DNS-03)
|
||||
# =====================================================================================
|
||||
# Root cause (verified on dev): the nodes carried a SINGLE upstream resolver. PiHole
|
||||
# (192.168.1.18) answers `*.jackson.lan` instantly but its external recursion flaps —
|
||||
# 0/25 for registry-1.docker.io / quay.io / ghcr.io while gitea.jackson.lan resolved 5/5.
|
||||
# One upstream is a hard SPOF: containerd image pulls died with "lookup
|
||||
# registry-1.docker.io: Try again", and CoreDNS (`forward . /etc/resolv.conf`) flooded its
|
||||
# log with "i/o timeout" to 192.168.1.18. The fix is ADDITIVE — PiHole stays first so the
|
||||
# local zone and the ad-blocking policy are untouched; a second upstream is only consulted
|
||||
# when the first fails. See defaults/main.yml for the ordering rule.
|
||||
#
|
||||
# WHY THIS DETECTS INSTEAD OF ASSUMING. An earlier attempt (fix/node-dns-resilient-upstream,
|
||||
# dropped) wrote /etc/netplan/99-k3s-dns.yaml and ran `netplan apply`. netplan is NOT
|
||||
# installed on these nodes — they run the Debian 13 trixie generic cloud image
|
||||
# (infra/tofu/variables.tf) and netplan is not in node_baseline_packages — so that handler
|
||||
# would have failed command-not-found and broken node-baseline.yml on EVERY node. Worse,
|
||||
# the two environments do not even share a resolver stack: dev's /etc/resolv.conf is the
|
||||
# systemd-resolved stub (127.0.0.53) while prod's is a directly-managed file listing
|
||||
# 192.168.2.1 (it has no cloud-init at all — bare metal). So the mechanism is DETECTED per
|
||||
# node and the matching seam is written. Adding a third mechanism means adding a branch
|
||||
# here, not rewriting the role.
|
||||
#
|
||||
# All of it is `dns`-tagged, so `--tags packages` (the prod package-floor escape hatch
|
||||
# documented at the top of this file) still installs nothing but packages.
|
||||
|
||||
- name: Determine whether systemd-resolved is running on this node
|
||||
ansible.builtin.command: systemctl is-active systemd-resolved
|
||||
register: node_baseline_resolved_active
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
tags: [dns]
|
||||
|
||||
- name: Determine whether NetworkManager is running (it rewrites /etc/resolv.conf)
|
||||
ansible.builtin.command: systemctl is-active NetworkManager
|
||||
register: node_baseline_nm_active
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
tags: [dns]
|
||||
|
||||
# follow: false — we need to know whether /etc/resolv.conf IS a symlink and where it
|
||||
# points, which is the strongest single signal of who owns the file.
|
||||
- name: Inspect /etc/resolv.conf itself (symlink or real file, and its target)
|
||||
ansible.builtin.stat:
|
||||
path: /etc/resolv.conf
|
||||
follow: false
|
||||
register: node_baseline_resolv_stat
|
||||
tags: [dns]
|
||||
|
||||
- name: Read the current /etc/resolv.conf contents
|
||||
ansible.builtin.slurp:
|
||||
src: /etc/resolv.conf
|
||||
register: node_baseline_resolv_slurp
|
||||
failed_when: false
|
||||
tags: [dns]
|
||||
|
||||
# resolvconf/openresolv keeps the authoritative copy under here and regenerates
|
||||
# /etc/resolv.conf from it, so writing /etc/resolv.conf directly would be silently undone.
|
||||
- name: Detect a resolvconf/openresolv installation that would clobber /etc/resolv.conf
|
||||
ansible.builtin.stat:
|
||||
path: /etc/resolvconf/resolv.conf.d
|
||||
register: node_baseline_resolvconf_dir
|
||||
tags: [dns]
|
||||
|
||||
# THE DETECTION. Three outcomes, in priority order:
|
||||
# systemd-resolved — resolved is running AND it owns /etc/resolv.conf, proven either by
|
||||
# the symlink pointing into /run/systemd/resolve/ or by the file containing only
|
||||
# resolved's loopback stub (127.0.0.53 / 127.0.0.54). Writing a resolved drop-in is
|
||||
# then the ONLY thing that changes real behaviour; writing resolv.conf would be
|
||||
# overwritten by resolved (or, worse, land in /run and vanish at reboot).
|
||||
# contended — nobody's stub, but resolvconf or NetworkManager is present/running and
|
||||
# would regenerate the file underneath us. We do NOT write. The role fails loudly
|
||||
# below rather than leaving an operator believing a change stuck when it did not.
|
||||
# resolv-conf-file — a plain, directly-managed /etc/resolv.conf (prod's shape today).
|
||||
# Safe to own outright.
|
||||
# resolved running but NOT owning resolv.conf correctly falls through to the file branch:
|
||||
# in that state the drop-in would change nothing glibc can see.
|
||||
- name: Detect which resolver mechanism this node actually uses
|
||||
ansible.builtin.set_fact:
|
||||
node_baseline_dns_mechanism: >-
|
||||
{{
|
||||
'systemd-resolved' if (node_baseline_resolved_running and node_baseline_resolved_owns_resolv)
|
||||
else ('contended' if (node_baseline_nm_running or node_baseline_resolvconf_present)
|
||||
else 'resolv-conf-file')
|
||||
}}
|
||||
vars:
|
||||
node_baseline_resolved_running: "{{ (node_baseline_resolved_active.stdout | default('') | trim) == 'active' }}"
|
||||
node_baseline_nm_running: "{{ (node_baseline_nm_active.stdout | default('') | trim) == 'active' }}"
|
||||
node_baseline_resolvconf_present: "{{ node_baseline_resolvconf_dir.stat.exists | default(false) }}"
|
||||
node_baseline_resolv_text: "{{ (node_baseline_resolv_slurp.content | default('') | b64decode) }}"
|
||||
node_baseline_resolv_ns: "{{ node_baseline_resolv_text | regex_findall('(?m)^\\s*nameserver\\s+(\\S+)') }}"
|
||||
node_baseline_resolved_owns_resolv: >-
|
||||
{{ ('/run/systemd/resolve/' in (node_baseline_resolv_stat.stat.lnk_source | default('')))
|
||||
or ((node_baseline_resolv_ns | length) > 0
|
||||
and (node_baseline_resolv_ns | reject('in', ['127.0.0.53', '127.0.0.54']) | list | length) == 0) }}
|
||||
tags: [dns]
|
||||
|
||||
- name: Report the detected resolver mechanism
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
resolver mechanism = {{ node_baseline_dns_mechanism }}
|
||||
(systemd-resolved is-active={{ node_baseline_resolved_active.stdout | default('') | trim }},
|
||||
NetworkManager is-active={{ node_baseline_nm_active.stdout | default('') | trim }},
|
||||
/etc/resolv.conf islnk={{ node_baseline_resolv_stat.stat.islnk | default(false) }}
|
||||
target={{ node_baseline_resolv_stat.stat.lnk_source | default('(not a symlink)') }},
|
||||
resolvconf dir={{ node_baseline_resolvconf_dir.stat.exists | default(false) }})
|
||||
tags: [dns]
|
||||
|
||||
# Same shape, and the same reason, as the data-disk assert above: this environment's
|
||||
# upstream list is an OPERATOR DECISION, and a placeholder that fails LOUDLY beats both a
|
||||
# guess and a silent inheritance of dev's PiHole. group_vars/k3s_prod/vars.yml deliberately
|
||||
# carries a BLOCKED second entry (see its comments and PROD-PROVISIONING.md §4): prod's
|
||||
# 192.168.2.1 is currently gateway AND only upstream, and choosing what sits behind it is a
|
||||
# network decision this role must not make. Running against prod stops HERE, before
|
||||
# anything is written.
|
||||
- name: Assert this environment's DNS upstream list has actually been decided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- node_baseline_dns_servers | length >= 2
|
||||
- "'BLOCKED' not in (node_baseline_dns_servers | join(' '))"
|
||||
fail_msg: >-
|
||||
node_baseline_dns_servers is not a decided, redundant upstream list for this host's
|
||||
environment (got: {{ node_baseline_dns_servers | join(', ') }}). A single upstream is
|
||||
the exact SPOF this task exists to remove — it already broke containerd image pulls
|
||||
and CoreDNS forwarding on dev — so the role will not configure one, and it will NOT
|
||||
guess a second server: a public resolver would bypass the local zone and the
|
||||
ad-blocking policy, which is a decision, not a default. Decide the second upstream,
|
||||
record it in infra/PROD-PROVISIONING.md §4, and set node_baseline_dns_servers in
|
||||
inventory/group_vars/k3s_prod/vars.yml in the SAME change. To install only the
|
||||
package floor meanwhile, re-run with `--tags packages`, which skips every DNS task.
|
||||
quiet: true
|
||||
tags: [dns]
|
||||
|
||||
# The `contended` outcome. Not a warning: an operator who is told "changed" while another
|
||||
# daemon quietly reverts the file is worse off than one who is told nothing happened.
|
||||
- name: Refuse to manage /etc/resolv.conf while another resolver manager owns it
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
/etc/resolv.conf on this node is not the systemd-resolved stub, but
|
||||
resolvconf/openresolv (/etc/resolvconf/resolv.conf.d present:
|
||||
{{ node_baseline_resolvconf_dir.stat.exists | default(false) }}) or NetworkManager
|
||||
(is-active: {{ node_baseline_nm_active.stdout | default('') | trim }}) is in play and
|
||||
would regenerate the file underneath this role. Writing it here would look like it
|
||||
worked and silently revert. Add the matching branch to roles/node_baseline (the
|
||||
`dns`-tagged block) for whichever manager this node really uses — do not force it.
|
||||
when: node_baseline_dns_mechanism == 'contended'
|
||||
tags: [dns]
|
||||
|
||||
# --- Mechanism A: systemd-resolved (dev's shape today) -------------------------------
|
||||
# A drop-in, never an edit of /etc/systemd/resolved.conf, so a package upgrade of that file
|
||||
# never conflicts. `DNS=` is one ORDERED list inside a single resolved scope: resolved uses
|
||||
# the first server and only rotates to the next when it fails, which is exactly the
|
||||
# PiHole-primary/fallback behaviour wanted — it is NOT a race between two servers, so a
|
||||
# gateway NXDOMAIN can never beat PiHole to a jackson.lan answer while PiHole is healthy.
|
||||
- name: Ensure the systemd-resolved drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/resolved.conf.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: node_baseline_dns_mechanism == 'systemd-resolved'
|
||||
tags: [dns]
|
||||
|
||||
- name: Configure resilient DNS upstreams via a systemd-resolved drop-in
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/resolved.conf.d/99-k3s-dns.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
content: |
|
||||
# Managed by the Ansible node_baseline role (tags: dns) — do not edit by hand.
|
||||
# Ordered upstream list: the FIRST server is the local-zone/ad-blocking resolver and
|
||||
# stays authoritative; the rest exist only so external resolution (containerd image
|
||||
# pulls, CoreDNS `forward . /etc/resolv.conf`) survives its recursion flapping.
|
||||
[Resolve]
|
||||
DNS={{ node_baseline_dns_servers | join(' ') }}
|
||||
Domains={{ node_baseline_dns_search | join(' ') }}
|
||||
# Reset to empty deliberately. systemd ships compiled-in public fallback resolvers
|
||||
# (Cloudflare/Google) that resolved would reach for if the list above ever came up
|
||||
# empty — which would silently bypass BOTH the local zone and the network's
|
||||
# ad-blocking. This fix adds redundancy; it never adds a bypass.
|
||||
FallbackDNS=
|
||||
notify: Restart systemd-resolved
|
||||
when: node_baseline_dns_mechanism == 'systemd-resolved'
|
||||
tags: [dns]
|
||||
|
||||
# --- Mechanism B: a directly-managed /etc/resolv.conf (prod's shape today) -----------
|
||||
# Only reached when nothing else owns the file (the `contended` branch above already
|
||||
# stopped the play otherwise). glibc walks `nameserver` lines in order and falls through
|
||||
# on timeout, so ordering carries the same policy as the resolved list.
|
||||
# NOTE this REPLACES the file: any nameserver currently there and NOT in
|
||||
# node_baseline_dns_servers is dropped. That is the point — the list in group_vars is the
|
||||
# whole declared truth — so an environment that wants its existing IPv6 upstreams kept
|
||||
# must list them there too. `backup: true` leaves the pre-role copy beside it the first
|
||||
# time it changes, because this file is load-bearing for reaching the node at all.
|
||||
- name: Configure resilient DNS upstreams directly in /etc/resolv.conf
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/resolv.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
backup: true
|
||||
content: |
|
||||
# Managed by the Ansible node_baseline role (tags: dns) — do not edit by hand.
|
||||
# Ordered upstream list: the FIRST server is the local-zone resolver and stays
|
||||
# authoritative; the rest exist only so external resolution (containerd image pulls,
|
||||
# CoreDNS `forward . /etc/resolv.conf`) survives its recursion flapping.
|
||||
{% if node_baseline_dns_search | length > 0 %}
|
||||
search {{ node_baseline_dns_search | join(' ') }}
|
||||
{% endif %}
|
||||
{% for server in node_baseline_dns_servers %}
|
||||
nameserver {{ server }}
|
||||
{% endfor %}
|
||||
{% if node_baseline_dns_options | length > 0 %}
|
||||
options {{ node_baseline_dns_options | join(' ') }}
|
||||
{% endif %}
|
||||
when: node_baseline_dns_mechanism == 'resolv-conf-file'
|
||||
tags: [dns]
|
||||
@@ -0,0 +1,156 @@
|
||||
# `node_health` — read-only node hardware health
|
||||
|
||||
Sibling of `roles/cluster_verify`. `cluster_verify` answers *"is the cluster healthy"*;
|
||||
`node_health` answers *"is the hardware under it healthy"*. Same posture: it **inspects and
|
||||
reports, it never changes the node**.
|
||||
|
||||
Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-03**
|
||||
(bare-metal parity with dev) and **R-OBS-04** (node-level observability).
|
||||
|
||||
```bash
|
||||
# prod (all nodes in the k3s_prod group)
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'cd /work/infra/ansible && ansible-playbook playbooks/node-health.yml -e node_health_hosts=k3s_prod'
|
||||
|
||||
# dev (default target), one node
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'cd /work/infra/ansible && ansible-playbook playbooks/node-health.yml --limit k3s-dev-1'
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
- **Read-only and side-effect-free.** It reads `/sys`, `/proc`, `/etc`, runs `lsblk`/`lscpu`,
|
||||
and — only when it can escalate — `smartctl`. It writes nothing to the node and
|
||||
**installs nothing**. `smartmontools` is declared in `node_baseline_packages`; if it is
|
||||
absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31:
|
||||
package installation is `node_baseline`'s job, never a health check's, never a manual
|
||||
`apt-get`).
|
||||
- **A skip is honest; a fake green is a defect.** Every check is `PASS` / `WARN` / `FAIL` /
|
||||
`SKIP` / `INFO`, and a `SKIP` always carries *why* it could not be measured. Nothing that
|
||||
was not read is reported as passing. The summary ends with a dedicated
|
||||
"NOT READ (and why) — these are UNKNOWNS, not passes" section.
|
||||
- **Works on bare metal and VMs.** Everything absent in a guest (SMART on virtio, DMI,
|
||||
EDAC, `coretemp`, CPU throttle counters) skips cleanly instead of erroring. Verified
|
||||
against both `k3s-prod-1` (HP EliteDesk, bare metal) and `k3s-dev-1` (Proxmox VM).
|
||||
- **Never `become`s unless it must.** Only SMART needs root.
|
||||
|
||||
## What it checks
|
||||
|
||||
| id | what |
|
||||
|---|---|
|
||||
| `access.privilege` | whether `become` works at all (gates the root-only checks) |
|
||||
| `storage.smart.<disk>` | overall-health, normalised-vs-threshold trips, pending/uncorrectable/CRC counters, lifetime remaining, wear + endurance context, drive temperature — **root only**. See [How the SMART verdict is reached](#how-the-smart-verdict-is-reached) |
|
||||
| `storage.io_errors` | kernel SCSI/ATA `ioerr_cnt` / `iotmo_cnt` per disk (no root needed) |
|
||||
| `storage.capacity`, `storage.inodes` | worst mounted filesystem, space and inodes |
|
||||
| `storage.layout` | physical disks, and whether any device is genuinely free for `node_baseline_longhorn_device` / `node_baseline_etcd_device` |
|
||||
| `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite, EDAC correctable/uncorrectable counters |
|
||||
| `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) |
|
||||
| `thermal.max` | hottest sensor across thermal zones and hwmon chips |
|
||||
| `pressure.cpu` / `.memory` / `.io` | PSI `some avg60` — dev learned to care about CPU PSI specifically |
|
||||
| `network.link`, `network.errors`, `network.resolvers` | link state/speed, NIC error counters, DNS upstream redundancy per address family |
|
||||
| `os.reboot_required`, `os.inventory`, `firmware.inventory` | pending reboot, OS/kernel/uptime, chassis + BIOS |
|
||||
|
||||
Thresholds live in `defaults/main.yml` (`node_health_thresholds`); no verdict is hardcoded
|
||||
in task logic.
|
||||
|
||||
## Output
|
||||
|
||||
1. A human summary printed in the play output.
|
||||
2. The same summary and a structured JSON record under
|
||||
`infra/ansible/_artifacts/node-health/<host>.{txt,json}`.
|
||||
|
||||
`_artifacts/` is in `infra/ansible/.gitignore` — the same convention as
|
||||
`conformance/_artifacts/`. Generated output is never committed (it also carries drive
|
||||
serial numbers).
|
||||
|
||||
## The `become` subtlety (read before editing)
|
||||
|
||||
`ansible_become` set as an inventory/group var — which is exactly what
|
||||
`inventory/group_vars/k3s_prod/vars.yml` does — is a **connection variable**, and
|
||||
connection variables **outrank** the `become:` play/task keyword. Verified on
|
||||
ansible-core 2.20: a task saying `become: false` still tries to sudo and, on prod, dies
|
||||
with `Missing sudo password` before the module runs. So this role overrides the
|
||||
**variable** (`vars: ansible_become: false`) on the unprivileged work, and probes
|
||||
escalation exactly once before attempting SMART.
|
||||
|
||||
Equally verified: `failed_when: false` does **not** catch a missing-sudo-password error
|
||||
(it is raised by the become plugin before the failure expression is evaluated);
|
||||
`ignore_errors: true` does, and still populates the registered variable. That is why the
|
||||
escalation probe uses `ignore_errors` — anything else turns "no vault yet" into a hard,
|
||||
misleading play failure.
|
||||
|
||||
## How the SMART verdict is reached
|
||||
|
||||
**SMART raw values are vendor-defined and this role does not judge on them.** smartctl(8)
|
||||
is explicit: "starting with ATA/ATAPI-4, revision 4, the meaning of these Attribute fields
|
||||
has been made entirely vendor-specific."
|
||||
|
||||
This is not theoretical. The first real run of this role FAILED `k3s-prod-1`'s
|
||||
Crucial/Micron C400 (`C400-MTFDDAC512MAM`, fw `070H`) on `reallocated=61440`, read as a
|
||||
plain bad-sector count. Micron's own TN-FD-20 *SMART Attributes for the C400 SSD* defines
|
||||
attribute 5 not as a sector count but as **Reallocated Block Count**, whose raw is "the
|
||||
grown NAND Flash bad-block count multiplied by the Block_Sector_Count, which is 256 x 8 for
|
||||
64Gb NAND ... or 256 x 16 for 128Gb NAND (256GB and 512GB)". For this 512GB drive that
|
||||
multiplier is 256 x 16 = 4096, and 61440 / 4096 = **15** — corroborated on the same drive by
|
||||
attribute 170 `Grown_Failing_Block_Ct` = 15 and attribute 196 `Reallocated_Event_Count` = 15.
|
||||
Fifteen grown bad blocks, not sixty-one thousand bad sectors. The verdict was wrong by a
|
||||
factor of 4096 because it assumed a unit the ATA spec does not guarantee.
|
||||
|
||||
### What the check judges on instead
|
||||
|
||||
Vendor-**independent** signals only, each of which means the same thing on every drive:
|
||||
|
||||
1. **The drive's own overall-health self-assessment** (`smart_status.passed`) — FAIL.
|
||||
2. **Normalised VALUE vs the MANUFACTURER'S OWN THRESHOLD**, taken from smartctl's
|
||||
`when_failed` field (`"now"` / `"past"` / `""`). Per smartctl(8), `FAILING_NOW` means the
|
||||
normalised value is at or below the threshold *the manufacturer set for that attribute on
|
||||
that model*; a dash means it is OK now and has never failed. This needs no per-vendor
|
||||
knowledge from us — the vendor already encoded the answer. Pre-fail failing now → FAIL;
|
||||
Old_age failing now, or anything that failed in the past → WARN.
|
||||
3. **ATA-standard error counters** whose raw meaning is a plain event count everywhere:
|
||||
197 pending, 198 offline-uncorrectable, 187 reported-uncorrectable → WARN/FAIL;
|
||||
199 CRC (SATA link, not media) → WARN only.
|
||||
4. **NVMe's structured health log** — `critical_warning`, `media_errors`, `percentage_used`.
|
||||
5. **Wear from the NORMALISED lifetime indicator** (ATA 202/231/233 `value`, or NVMe
|
||||
`percentage_used`) — a percentage by definition, and therefore portable where raw wear
|
||||
counts are not.
|
||||
|
||||
### What is reported but never produces a FAIL
|
||||
|
||||
Vendor raw counts are real information and are all printed with their numbers — grown
|
||||
bad-block count, reallocation events (196), erase/program failures, unexpected power losses,
|
||||
and attribute 5's raw shown **beside** the normalised reading that contradicts it. They can
|
||||
raise a **WARN**, never a FAIL, because their units are vendor-defined.
|
||||
|
||||
The endurance counters live in the vendor-specific attribute-ID range (170-174), so they are
|
||||
matched by **the name smartctl's drive database resolved for that model**, not by a bare ID:
|
||||
`drivedb.h` is smartmontools' own per-(model, firmware) translation, so keying off its
|
||||
resolved name borrows that portability instead of hardcoding one vendor's layout. On a drive
|
||||
drivedb does not recognise, the name is `Unknown_Attribute`, nothing matches, and the field
|
||||
stays `null` — no claim is made either way.
|
||||
|
||||
### Two honesty traps this check does not fall into
|
||||
|
||||
- **Zero is not a clean bill of health.** Micron TN-FD-20 states attribute 197 on the C400
|
||||
"will always be 0, as reallocation will be done on-the-fly". A non-zero 197 is a finding;
|
||||
a zero one proves nothing. The role reports the number and does not infer health from it.
|
||||
- **A quiet drive is not necessarily a good drive.** `overall-health=PASSED` with every
|
||||
threshold un-tripped can still sit alongside a third of rated life consumed, grown bad
|
||||
blocks, erase failures and unprotected power losses — which is exactly `k3s-prod-1`. Those
|
||||
produce a WARN *with the numbers*, because a check that called that drive "fine" would be
|
||||
worse than the false FAIL it replaced.
|
||||
|
||||
## Known limits
|
||||
|
||||
- Without root: **no SMART** (wear, power-on hours, error counters, drive
|
||||
temperature) and no DMI serial numbers. On prod that means creating
|
||||
`inventory/group_vars/k3s_prod/vault.yml` (see that directory's README) and running
|
||||
`node-baseline` so `smartmontools` exists — in that order.
|
||||
- EDAC reports nothing on platforms with no ECC memory controller bound. That is
|
||||
"ECC status unknown", not "no errors", and the role says so.
|
||||
- SMART wear and endurance context depends on smartmontools' `drivedb.h` knowing the drive.
|
||||
On an unrecognised model the vendor attributes are `Unknown_Attribute`; the portable
|
||||
signals (overall health, normalised-vs-threshold, 197/198/187/199, NVMe log) still apply,
|
||||
but the wear/endurance context SKIPs rather than guessing.
|
||||
- `ioerr_cnt` counts commands the kernel saw fail, which includes benign rejected or
|
||||
unsupported commands during discovery. Attribution needs `dmesg`, which needs root.
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
# node_health — READ-ONLY hardware/health inspection of a node (bare metal or VM).
|
||||
#
|
||||
# Sibling of roles/cluster_verify: cluster_verify answers "is the CLUSTER healthy",
|
||||
# node_health answers "is the HARDWARE under it healthy". Same posture — it inspects and
|
||||
# reports, it never changes the node. Deliberately NOT part of node_baseline: baseline
|
||||
# installs and configures, health only looks. That separation is why this role must never
|
||||
# `apt install` anything (smartmontools is declared in node_baseline_packages; if it is
|
||||
# missing here the SMART checks SKIP with a reason, they do not self-heal).
|
||||
#
|
||||
# Serves R-ENV-01 (reproducible node provisioning — you cannot certify a node you have not
|
||||
# measured), R-ENV-03 (bare-metal parity) and R-OBS-04 (node-level observability).
|
||||
|
||||
# --- Reporting ---------------------------------------------------------------------
|
||||
# Artifacts land under infra/ansible/_artifacts/node-health/, mirroring the repo's
|
||||
# conformance/_artifacts/ convention: generated output, regenerated on every run, listed
|
||||
# in .gitignore, never committed (it also carries drive serial numbers).
|
||||
node_health_artifact_dir: "{{ playbook_dir }}/../_artifacts/node-health"
|
||||
node_health_write_artifacts: true
|
||||
# Print the human summary to the play output as well as writing it to the artifact dir.
|
||||
node_health_show_summary: true
|
||||
# Report-only by default: a health check that aborts the play hides the other 20 findings.
|
||||
# Set true (e.g. in CI or a pre-build gate) to make any FAIL check end the play non-zero.
|
||||
node_health_fail_on_fail: false
|
||||
# Same, one step stricter: also fail on WARN.
|
||||
node_health_fail_on_warn: false
|
||||
|
||||
# --- Root-only checks ---------------------------------------------------------------
|
||||
# Everything this role can read WITHOUT root, it reads without root. Only SMART needs
|
||||
# escalation (the ATA/NVMe passthrough ioctls are root-only). The role PROBES become
|
||||
# first and, if escalation is unavailable (e.g. prod's vaulted become password is not
|
||||
# present — infra/PROD-PROVISIONING.md §5), those checks report SKIPPED with the exact
|
||||
# reason. Never a hard failure, and never a fake PASS: a skip is honest.
|
||||
node_health_try_privileged: true
|
||||
# --nocheck=standby: never spin up a sleeping disk just to read it. Read-only hygiene.
|
||||
node_health_smartctl_args: "--json=c --all --nocheck=standby"
|
||||
|
||||
# --- Thresholds ---------------------------------------------------------------------
|
||||
# All verdicts come from here, so a site can retune without touching task logic.
|
||||
node_health_thresholds:
|
||||
# Filesystems
|
||||
fs_used_pct_warn: 80
|
||||
fs_used_pct_fail: 90
|
||||
inode_used_pct_warn: 80
|
||||
# Memory (percentage of MemTotal still available)
|
||||
mem_available_pct_warn: 15
|
||||
mem_available_pct_fail: 5
|
||||
# Temperatures (deg C, any sensor)
|
||||
temp_warn_c: 70
|
||||
temp_fail_c: 85
|
||||
# Pressure-stall: "some" avg60, percent of wall time stalled. Dev learned to care about
|
||||
# CPU PSI specifically (it shows saturation long before load average makes it obvious).
|
||||
psi_some_avg60_warn: 10.0
|
||||
psi_some_avg60_fail: 40.0
|
||||
# Network
|
||||
nic_min_speed_mbps: 1000
|
||||
# A single upstream resolver is a known SPOF on this project — it broke containerd
|
||||
# image pulls and CoreDNS forwarding on dev (DECISIONS-LOG 2026-08-31, finding 5).
|
||||
# Counted against the node's REAL upstreams: where systemd-resolved is in use the
|
||||
# collector looks behind the 127.0.0.53 stub rather than counting it as one server.
|
||||
# The fix side of this is the `dns`-tagged block in roles/node_baseline.
|
||||
min_nameservers: 2
|
||||
# --- SMART (only evaluated when smartctl actually ran) -----------------------------
|
||||
#
|
||||
# WHY THESE THRESHOLDS AND NOT "reallocated sectors" ANY MORE.
|
||||
# The first real run of this role FAILED k3s-prod-1's Crucial/Micron C400 on
|
||||
# `reallocated=61440`, read as a plain bad-sector count. That verdict was WRONG, and it
|
||||
# was wrong for a structural reason, not a tuning one: SMART *raw* values are vendor
|
||||
# defined. smartctl(8) says so outright -- "starting with ATA/ATAPI-4, revision 4, the
|
||||
# meaning of these Attribute fields has been made entirely vendor-specific". Micron's own
|
||||
# TN-FD-20 (SMART Attributes for the C400 SSD) defines ID 5 not as a sector count but as
|
||||
# "Reallocated Block Count", raw = "the grown NAND Flash bad-block count multiplied by the
|
||||
# Block_Sector_Count, which is 256 x 8 for 64Gb NAND ... or 256 x 16 for 128Gb NAND (256GB
|
||||
# and 512GB)". 256 x 16 = 4096, and 61440 / 4096 = 15 -- corroborated independently by
|
||||
# ID 170 Grown_Failing_Block_Ct = 15 and ID 196 Reallocated_Event_Count = 15. Fifteen
|
||||
# grown bad blocks, not sixty-one thousand bad sectors.
|
||||
#
|
||||
# So no threshold below is ever applied to attribute 5's raw value. The verdict comes from
|
||||
# the signals smartctl itself defines as vendor-INDEPENDENT:
|
||||
# * the drive's own overall-health self-assessment (smart_status.passed);
|
||||
# * NORMALISED value vs THRESHOLD, i.e. smartctl's when_failed ("now"/"past"), which is
|
||||
# the manufacturer's OWN trip point for that attribute on that drive;
|
||||
# * the ATA-standard error counters whose raw meaning is a plain event count everywhere:
|
||||
# 197 pending, 198 offline-uncorrectable, 187 reported-uncorrectable, 199 CRC;
|
||||
# * NVMe's structured health log (critical_warning, media_errors, percentage_used).
|
||||
# Vendor raw counts are still collected and printed -- they are real information -- but
|
||||
# they can only ever raise a WARN with the number shown, never a FAIL on their own.
|
||||
#
|
||||
# Hard-error counters (197/198/187 raw, NVMe media_errors). A plain event count on every
|
||||
# drive that implements them. NOTE the converse is not evidence: some drives (the C400
|
||||
# among them, per TN-FD-20) hard-code 197 to 0 because reallocation happens on the fly, so
|
||||
# "pending = 0" proves nothing. Nonzero is a finding; zero is not a clean bill of health.
|
||||
smart_pending_warn: 1
|
||||
smart_pending_fail: 10
|
||||
smart_uncorrectable_warn: 1
|
||||
smart_uncorrectable_fail: 10
|
||||
# 199 UDMA_CRC_Error_Count -- SATA link/cable integrity, not media. WARN only: it is a
|
||||
# lifetime count that never resets, so a historic cable reseat should not FAIL a node.
|
||||
smart_crc_warn: 1
|
||||
# Wear, from the NORMALISED lifetime indicator (ATA 202/231/233 value, or NVMe
|
||||
# percentage_used) -- a percentage by definition, so portable in a way raw wear counts
|
||||
# are not. ..._remaining_* are the "this drive is nearly used up" bands; ..._used_pct_warn
|
||||
# is the softer "this is not a fresh drive, know that before you put etcd on it" notice
|
||||
# that PROD-PROVISIONING.md Section 2 actually needs.
|
||||
smart_lifetime_remaining_warn_pct: 20
|
||||
smart_lifetime_remaining_fail_pct: 10
|
||||
smart_life_used_pct_warn: 25
|
||||
# Reallocation ACTIVITY. 196 Reallocated_Event_Count is an ATA-standard *event* count
|
||||
# (one per remap operation) and is the portable stand-in for attribute 5's unportable raw.
|
||||
# WARN-only by design: remapping is what a drive is supposed to do, and how many events
|
||||
# are "too many" is drive-specific -- the manufacturer already encodes that answer in
|
||||
# attribute 5's normalised threshold, which the when_failed check above honours.
|
||||
smart_realloc_events_warn: 1
|
||||
# Endurance-stress counters, matched by the NAME smartctl's drive database resolved for
|
||||
# this model rather than by a bare vendor ID -- drivedb IS the portability layer. On a
|
||||
# drive smartctl does not recognise these simply do not match and nothing is claimed.
|
||||
smart_erase_fail_warn: 1
|
||||
smart_program_fail_warn: 1
|
||||
# Unexpected power loss on a consumer SSD with no power-loss protection: each one is a
|
||||
# chance of a torn/lost in-flight write. Directly relevant to etcd's fsync contract.
|
||||
smart_unexpected_power_loss_warn: 10
|
||||
smart_temp_warn_c: 55
|
||||
smart_temp_fail_c: 70
|
||||
# Power-on hours past which a consumer SSD is simply old. Informational — age alone is
|
||||
# not a failure, but it belongs in the operator's field of view when deciding whether a
|
||||
# drive may hold production state (PROD-PROVISIONING.md §2).
|
||||
smart_power_on_hours_notice: 40000
|
||||
|
||||
# ATA SMART attribute IDs surfaced verbatim in the report. Vendors disagree on names, so
|
||||
# the report prints whatever the drive calls them and the checks key off the IDs.
|
||||
# 170-174 and 189 are the Micron/Crucial (and, by convention, most SSD controllers')
|
||||
# endurance and grown-bad-block counters; they are the CONTEXT that made the C400 verdict
|
||||
# above provable, and leaving them out of the report is what let a single misread raw value
|
||||
# stand unchallenged. 1 and 12 are included so the error-rate and power-cycle baselines are
|
||||
# visible next to them.
|
||||
node_health_smart_attribute_ids: [1, 5, 9, 12, 170, 171, 172, 173, 174, 177, 179, 181,
|
||||
182, 183, 184, 187, 188, 189, 190, 194, 195, 196, 197,
|
||||
198, 199, 202, 206, 231, 232, 233, 241, 242, 246]
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# node_health_collect.py — UNPRIVILEGED hardware/health collector for one node.
|
||||
#
|
||||
# Run by roles/node_health via ansible.builtin.script. Prints ONE JSON document on
|
||||
# stdout and nothing else. It is strictly READ-ONLY: it opens files under /sys, /proc
|
||||
# and /etc, and shells out only to read-only queries (`lsblk`, `lscpu`, and `resolvectl dns`
|
||||
# — the last only when /etc/resolv.conf turns out to be the systemd-resolved stub and the
|
||||
# real upstreams have to be asked of resolved). It never writes to the node, never installs
|
||||
# anything, and never needs root.
|
||||
#
|
||||
# WHY A COLLECTOR SCRIPT AND NOT 40 ANSIBLE TASKS
|
||||
# Everything here is "read a sysfs file that may or may not exist and may or may not
|
||||
# be readable". Expressing that as tasks means ~40 SSH round trips plus fragile Jinja
|
||||
# parsing of `cat` output. One script is a single round trip, parses in Python where
|
||||
# it is legible, and — the point that matters for this repo — can express the
|
||||
# difference between "value is 0" and "value could not be read" HONESTLY, per field,
|
||||
# instead of collapsing both into an empty string. Anything unreadable is emitted as
|
||||
# null and appended to the top-level "unreadable" list with the reason. A skip is
|
||||
# honest; a fake zero is a defect.
|
||||
#
|
||||
# WHY NOT ANSIBLE'S OWN setup MODULE
|
||||
# `setup` gives DMI/CPU/memory/mounts, but none of the things this role exists for:
|
||||
# thermal zones, EDAC correctable-error counters, CPU throttle counters, PSI
|
||||
# (/proc/pressure), SCSI per-disk error counters, per-NIC error/drop statistics. The
|
||||
# role therefore runs with gather_facts disabled and takes everything from here, so
|
||||
# there is exactly one source of truth for the report and the JSON artifact.
|
||||
#
|
||||
# PORTABILITY (bare metal AND VMs — the role must be reusable against the dev nodes)
|
||||
# Every read is optional. A VM has no DMI product_name worth reading, no coretemp
|
||||
# hwmon, no EDAC, no SCSI error counters on virtio, and no SMART. All of those come
|
||||
# back null/absent rather than raising. Nothing in this file assumes an HP EliteDesk.
|
||||
#
|
||||
# Python 3.6+, standard library only (no pip, no ansible module deps).
|
||||
# =============================================================================
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SCHEMA = "node-health/1"
|
||||
|
||||
# Fields that could not be read, with why. Appended to by read_text()/read_int().
|
||||
UNREADABLE = []
|
||||
|
||||
|
||||
def read_text(path, optional=True):
|
||||
"""Read a small sysfs/procfs file. Returns stripped str, or None when unreadable.
|
||||
|
||||
`optional=False` records the failure in UNREADABLE so the report can say WHY a
|
||||
check was skipped (permission denied vs. not present are very different answers —
|
||||
the first is "give Ansible become", the second is "this hardware has no such
|
||||
sensor")."""
|
||||
try:
|
||||
with open(path, "r", errors="replace") as fh:
|
||||
return fh.read().strip()
|
||||
except FileNotFoundError:
|
||||
if not optional:
|
||||
UNREADABLE.append({"path": path, "reason": "not present"})
|
||||
return None
|
||||
except PermissionError:
|
||||
# ALWAYS recorded, even when optional: permission-denied is the signal that a
|
||||
# value exists but we lack root, which is exactly what the operator needs told.
|
||||
UNREADABLE.append({"path": path, "reason": "permission denied (needs root)"})
|
||||
return None
|
||||
except OSError as exc:
|
||||
UNREADABLE.append({"path": path, "reason": "read error: %s" % exc})
|
||||
return None
|
||||
|
||||
|
||||
def read_int(path, optional=True):
|
||||
raw = read_text(path, optional=optional)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def read_kv(path, sep=":"):
|
||||
"""Parse a `key: value` file (/proc/meminfo, /etc/os-release) into a dict."""
|
||||
raw = read_text(path)
|
||||
if raw is None:
|
||||
return None
|
||||
out = {}
|
||||
for line in raw.splitlines():
|
||||
if sep not in line:
|
||||
continue
|
||||
key, _, value = line.partition(sep)
|
||||
out[key.strip()] = value.strip().strip('"')
|
||||
return out
|
||||
|
||||
|
||||
def run(argv, timeout=20):
|
||||
"""Run a read-only query command. Returns (rc, stdout, stderr); rc None if absent."""
|
||||
if shutil.which(argv[0]) is None:
|
||||
return None, "", "%s not installed" % argv[0]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout
|
||||
)
|
||||
return (
|
||||
proc.returncode,
|
||||
proc.stdout.decode("utf-8", "replace"),
|
||||
proc.stderr.decode("utf-8", "replace"),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return None, "", str(exc)
|
||||
|
||||
|
||||
# --- DMI / firmware -----------------------------------------------------------------
|
||||
# /sys/class/dmi/id/* is world-readable EXCEPT the identifying serials
|
||||
# (product_serial, board_serial, chassis_serial, product_uuid — mode 0400 root). Those
|
||||
# come back null here and are reported as "needs root", not as "absent". A VM usually
|
||||
# has the directory but with hypervisor-ish values (QEMU/SeaBIOS), which is fine and
|
||||
# still worth recording.
|
||||
DMI_PUBLIC = [
|
||||
"sys_vendor",
|
||||
"product_name",
|
||||
"product_version",
|
||||
"product_family",
|
||||
"product_sku",
|
||||
"board_vendor",
|
||||
"board_name",
|
||||
"board_version",
|
||||
"bios_vendor",
|
||||
"bios_version",
|
||||
"bios_date",
|
||||
"bios_release",
|
||||
"ec_firmware_release",
|
||||
"chassis_type",
|
||||
"chassis_vendor",
|
||||
]
|
||||
DMI_ROOT_ONLY = ["product_serial", "board_serial", "chassis_serial", "product_uuid"]
|
||||
|
||||
|
||||
def collect_dmi():
|
||||
base = "/sys/class/dmi/id"
|
||||
if not os.path.isdir(base):
|
||||
return {"present": False}
|
||||
dmi = {"present": True}
|
||||
for field in DMI_PUBLIC:
|
||||
dmi[field] = read_text(os.path.join(base, field))
|
||||
# Read the root-only ones deliberately so PermissionError lands in UNREADABLE and
|
||||
# the operator can see exactly which fields root would add.
|
||||
for field in DMI_ROOT_ONLY:
|
||||
dmi[field] = read_text(os.path.join(base, field), optional=False)
|
||||
return dmi
|
||||
|
||||
|
||||
# --- CPU ----------------------------------------------------------------------------
|
||||
def collect_cpu():
|
||||
cpu = {}
|
||||
rc, out, _ = run(["lscpu", "-J"])
|
||||
lscpu = {}
|
||||
if rc == 0:
|
||||
try:
|
||||
for entry in json.loads(out).get("lscpu", []):
|
||||
lscpu[entry.get("field", "").rstrip(":")] = entry.get("data")
|
||||
except ValueError:
|
||||
lscpu = {}
|
||||
cpu["model"] = lscpu.get("Model name")
|
||||
cpu["vendor"] = lscpu.get("Vendor ID")
|
||||
cpu["architecture"] = lscpu.get("Architecture")
|
||||
cpu["cpus"] = lscpu.get("CPU(s)")
|
||||
cpu["threads_per_core"] = lscpu.get("Thread(s) per core")
|
||||
cpu["cores_per_socket"] = lscpu.get("Core(s) per socket")
|
||||
cpu["sockets"] = lscpu.get("Socket(s)")
|
||||
# "Virtualization: VT-x" on bare metal; "Hypervisor vendor: KVM" inside a VM. Both
|
||||
# are recorded — the first tells us KubeVirt can run here (R-PLAT-03), the second
|
||||
# tells us this node IS a guest and that SMART/DMI/EDAC skips are expected.
|
||||
cpu["virtualization"] = lscpu.get("Virtualization")
|
||||
cpu["hypervisor_vendor"] = lscpu.get("Hypervisor vendor")
|
||||
cpu["virtualization_type"] = lscpu.get("Virtualization type")
|
||||
cpu["is_virtual_guest"] = bool(lscpu.get("Hypervisor vendor"))
|
||||
cpu["kvm_device"] = os.path.exists("/dev/kvm")
|
||||
|
||||
flags = (read_text("/proc/cpuinfo") or "")
|
||||
cpu["hardware_virt_flag"] = bool(re.search(r"^flags\s*:.*\b(vmx|svm)\b", flags, re.M))
|
||||
|
||||
# cpufreq: governor/driver and the frequency window. Absent inside most VMs.
|
||||
cpufreq = "/sys/devices/system/cpu/cpu0/cpufreq"
|
||||
if os.path.isdir(cpufreq):
|
||||
cpu["scaling"] = {
|
||||
"governor": read_text(cpufreq + "/scaling_governor"),
|
||||
"driver": read_text(cpufreq + "/scaling_driver"),
|
||||
"base_khz": read_int(cpufreq + "/base_frequency"),
|
||||
"min_khz": read_int(cpufreq + "/cpuinfo_min_freq"),
|
||||
"max_khz": read_int(cpufreq + "/cpuinfo_max_freq"),
|
||||
"cur_khz": read_int(cpufreq + "/scaling_cur_freq"),
|
||||
}
|
||||
else:
|
||||
cpu["scaling"] = None
|
||||
|
||||
# Thermal throttling. These counters are cumulative since boot: non-zero means the
|
||||
# package or a core has ACTUALLY been clamped, which on a mini-PC in a cupboard is
|
||||
# the difference between "it fits" and "it cooks". Absent in VMs.
|
||||
throttle_dirs = sorted(glob.glob("/sys/devices/system/cpu/cpu*/thermal_throttle"))
|
||||
if throttle_dirs:
|
||||
core_total = 0
|
||||
pkg_max = 0
|
||||
core_time = 0
|
||||
for tdir in throttle_dirs:
|
||||
core_total += read_int(tdir + "/core_throttle_count") or 0
|
||||
core_time += read_int(tdir + "/core_throttle_total_time_ms") or 0
|
||||
pkg_max = max(pkg_max, read_int(tdir + "/package_throttle_count") or 0)
|
||||
cpu["throttle"] = {
|
||||
"core_throttle_count_total": core_total,
|
||||
"core_throttle_total_time_ms": core_time,
|
||||
"package_throttle_count": pkg_max,
|
||||
"cpus_reporting": len(throttle_dirs),
|
||||
}
|
||||
else:
|
||||
cpu["throttle"] = None
|
||||
|
||||
load = read_text("/proc/loadavg")
|
||||
if load:
|
||||
parts = load.split()
|
||||
cpu["loadavg"] = [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
cpu["runnable"] = parts[3] if len(parts) > 3 else None
|
||||
return cpu
|
||||
|
||||
|
||||
# --- Memory + EDAC ------------------------------------------------------------------
|
||||
def collect_memory():
|
||||
meminfo = read_kv("/proc/meminfo") or {}
|
||||
|
||||
def kb(key):
|
||||
raw = meminfo.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
mem = {
|
||||
"mem_total_kb": kb("MemTotal"),
|
||||
"mem_available_kb": kb("MemAvailable"),
|
||||
"mem_free_kb": kb("MemFree"),
|
||||
"swap_total_kb": kb("SwapTotal"),
|
||||
"swap_free_kb": kb("SwapFree"),
|
||||
"hugepages_total": kb("HugePages_Total"),
|
||||
}
|
||||
|
||||
# EDAC = the kernel's memory-controller error reporting. Only populated when the
|
||||
# platform has ECC RAM *and* a driver bound (mc0, mc1, ...). Consumer desktop
|
||||
# boards and VMs have /sys/devices/system/edac/mc but NO mcN under it — that is
|
||||
# "ECC not exposed", NOT "zero errors". The distinction is the whole point: we
|
||||
# report present=false and the check SKIPS rather than claiming a clean bill.
|
||||
controllers = sorted(glob.glob("/sys/devices/system/edac/mc/mc[0-9]*"))
|
||||
edac = {
|
||||
"sysfs_present": os.path.isdir("/sys/devices/system/edac/mc"),
|
||||
"controllers_present": bool(controllers),
|
||||
"controllers": [],
|
||||
"ce_total": None,
|
||||
"ue_total": None,
|
||||
}
|
||||
if controllers:
|
||||
ce_total = 0
|
||||
ue_total = 0
|
||||
for mc in controllers:
|
||||
ce = read_int(mc + "/ce_count")
|
||||
ue = read_int(mc + "/ue_count")
|
||||
edac["controllers"].append(
|
||||
{
|
||||
"name": os.path.basename(mc),
|
||||
"mc_name": read_text(mc + "/mc_name"),
|
||||
"size_mb": read_int(mc + "/size_mb"),
|
||||
"ce_count": ce,
|
||||
"ue_count": ue,
|
||||
"ce_noinfo_count": read_int(mc + "/ce_noinfo_count"),
|
||||
"ue_noinfo_count": read_int(mc + "/ue_noinfo_count"),
|
||||
}
|
||||
)
|
||||
ce_total += ce or 0
|
||||
ue_total += ue or 0
|
||||
edac["ce_total"] = ce_total
|
||||
edac["ue_total"] = ue_total
|
||||
mem["edac"] = edac
|
||||
return mem
|
||||
|
||||
|
||||
# --- Thermals -----------------------------------------------------------------------
|
||||
def collect_thermal():
|
||||
zones = []
|
||||
for zdir in sorted(glob.glob("/sys/class/thermal/thermal_zone*")):
|
||||
milli = read_int(zdir + "/temp")
|
||||
crit = None
|
||||
for trip in sorted(glob.glob(zdir + "/trip_point_*_type")):
|
||||
if (read_text(trip) or "") == "critical":
|
||||
crit = read_int(trip.replace("_type", "_temp"))
|
||||
break
|
||||
zones.append(
|
||||
{
|
||||
"zone": os.path.basename(zdir),
|
||||
"type": read_text(zdir + "/type"),
|
||||
"temp_c": round(milli / 1000.0, 1) if milli is not None else None,
|
||||
"critical_c": round(crit / 1000.0, 1) if crit is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
# hwmon carries the interesting per-chip sensors (coretemp package/cores, PCH, the
|
||||
# HP embedded controller). Also where FAN inputs live when the platform exposes them.
|
||||
chips = []
|
||||
for hdir in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
|
||||
chip = {"path": os.path.basename(hdir), "name": read_text(hdir + "/name"),
|
||||
"temps": [], "fans": []}
|
||||
for tin in sorted(glob.glob(hdir + "/temp*_input")):
|
||||
milli = read_int(tin)
|
||||
base = tin[: -len("_input")]
|
||||
chip["temps"].append(
|
||||
{
|
||||
"label": read_text(base + "_label") or os.path.basename(base),
|
||||
"temp_c": round(milli / 1000.0, 1) if milli is not None else None,
|
||||
"max_c": (lambda v: round(v / 1000.0, 1) if v is not None else None)(
|
||||
read_int(base + "_max")
|
||||
),
|
||||
"crit_c": (lambda v: round(v / 1000.0, 1) if v is not None else None)(
|
||||
read_int(base + "_crit")
|
||||
),
|
||||
}
|
||||
)
|
||||
for fin in sorted(glob.glob(hdir + "/fan*_input")):
|
||||
base = fin[: -len("_input")]
|
||||
chip["fans"].append(
|
||||
{
|
||||
"label": read_text(base + "_label") or os.path.basename(base),
|
||||
"rpm": read_int(fin),
|
||||
}
|
||||
)
|
||||
if chip["temps"] or chip["fans"]:
|
||||
chips.append(chip)
|
||||
return {"zones": zones, "hwmon": chips}
|
||||
|
||||
|
||||
# --- Pressure-stall (PSI) -----------------------------------------------------------
|
||||
# /proc/pressure/{cpu,memory,io}. Dev learned to care about CPU PSI (saturation shows
|
||||
# up here long before load average makes it obvious), so it is first-class in this role.
|
||||
# Requires CONFIG_PSI=y + psi=1 — absent on some kernels, which SKIPS honestly.
|
||||
def collect_pressure():
|
||||
out = {"available": os.path.isdir("/proc/pressure")}
|
||||
for resource in ("cpu", "memory", "io"):
|
||||
raw = read_text("/proc/pressure/" + resource)
|
||||
if raw is None:
|
||||
out[resource] = None
|
||||
continue
|
||||
parsed = {}
|
||||
for line in raw.splitlines():
|
||||
fields = line.split()
|
||||
if not fields:
|
||||
continue
|
||||
kind = fields[0] # "some" | "full"
|
||||
parsed[kind] = {}
|
||||
for token in fields[1:]:
|
||||
key, _, value = token.partition("=")
|
||||
try:
|
||||
parsed[kind][key] = float(value)
|
||||
except ValueError:
|
||||
parsed[kind][key] = value
|
||||
out[resource] = parsed
|
||||
return out
|
||||
|
||||
|
||||
# --- Storage ------------------------------------------------------------------------
|
||||
PSEUDO_FS = {
|
||||
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "pstore",
|
||||
"bpf", "securityfs", "debugfs", "tracefs", "configfs", "fusectl", "hugetlbfs",
|
||||
"mqueue", "efivarfs", "autofs", "binfmt_misc", "ramfs", "nsfs", "squashfs",
|
||||
"overlay", "fuse.portal",
|
||||
}
|
||||
|
||||
|
||||
def collect_storage():
|
||||
disks = []
|
||||
|
||||
# lsblk is the reliable source for model/serial/transport (it reads udev, and does
|
||||
# so WITHOUT root). sysfs fills in what lsblk does not expose: the SCSI-layer error
|
||||
# counters and the queue tunables.
|
||||
lsblk_map = {}
|
||||
rc, out, _ = run(
|
||||
["lsblk", "-J", "-b", "-o",
|
||||
"NAME,KNAME,PATH,TYPE,SIZE,ROTA,MODEL,SERIAL,TRAN,REV,FSTYPE,MOUNTPOINTS,PARTLABEL"]
|
||||
)
|
||||
lsblk_tree = []
|
||||
if rc == 0:
|
||||
try:
|
||||
lsblk_tree = json.loads(out).get("blockdevices", [])
|
||||
except ValueError:
|
||||
lsblk_tree = []
|
||||
|
||||
def walk(nodes):
|
||||
for node in nodes:
|
||||
lsblk_map[node.get("kname")] = node
|
||||
walk(node.get("children") or [])
|
||||
|
||||
walk(lsblk_tree)
|
||||
|
||||
for bdir in sorted(glob.glob("/sys/block/*")):
|
||||
kname = os.path.basename(bdir)
|
||||
# Skip pseudo block devices — they carry no hardware health and would only add
|
||||
# noise to the report (loop mounts, ramdisks, device-mapper stacks, CD-ROM).
|
||||
if re.match(r"^(loop|ram|sr|fd|dm-|zram|md)", kname):
|
||||
continue
|
||||
node = lsblk_map.get(kname, {})
|
||||
sectors = read_int(bdir + "/size")
|
||||
dev = bdir + "/device"
|
||||
disk = {
|
||||
"kname": kname,
|
||||
"path": node.get("path") or "/dev/" + kname,
|
||||
"model": node.get("model") or read_text(dev + "/model"),
|
||||
"vendor": read_text(dev + "/vendor"),
|
||||
"firmware_rev": node.get("rev") or read_text(dev + "/rev"),
|
||||
"serial": node.get("serial"),
|
||||
"transport": node.get("tran"),
|
||||
# A device can carry a filesystem DIRECTLY, with no partition table at all —
|
||||
# which is exactly how roles/node_baseline formats the Longhorn and etcd data
|
||||
# disks (mkfs on /dev/vdb, mounted by LABEL). Such a device has zero
|
||||
# partitions but is emphatically NOT spare, so both facts are recorded and
|
||||
# the "is there a free device" check keys off all three.
|
||||
"fstype": node.get("fstype"),
|
||||
"mountpoints": [m for m in (node.get("mountpoints") or []) if m],
|
||||
"size_bytes": (sectors * 512) if sectors is not None else node.get("size"),
|
||||
"rotational": (read_int(bdir + "/queue/rotational") == 1)
|
||||
if read_int(bdir + "/queue/rotational") is not None
|
||||
else node.get("rota"),
|
||||
"scheduler": read_text(bdir + "/queue/scheduler"),
|
||||
"logical_block_size": read_int(bdir + "/queue/logical_block_size"),
|
||||
"physical_block_size": read_int(bdir + "/queue/physical_block_size"),
|
||||
# SCSI/ATA error counters, readable WITHOUT root. Not a substitute for SMART
|
||||
# (no wear, no power-on hours, no reallocated sectors) but a genuine signal:
|
||||
# ioerr_cnt > 0 means the kernel saw command failures on this device.
|
||||
"scsi_counters": {
|
||||
"iodone_cnt": (lambda v: int(v, 16) if v else None)(
|
||||
read_text(dev + "/iodone_cnt")
|
||||
),
|
||||
"ioerr_cnt": (lambda v: int(v, 16) if v else None)(
|
||||
read_text(dev + "/ioerr_cnt")
|
||||
),
|
||||
"iotmo_cnt": (lambda v: int(v, 16) if v else None)(
|
||||
read_text(dev + "/iotmo_cnt")
|
||||
),
|
||||
}
|
||||
if (os.path.isdir(dev) and os.path.exists(dev + "/ioerr_cnt"))
|
||||
else None,
|
||||
"partitions": [],
|
||||
}
|
||||
for pdir in sorted(glob.glob(bdir + "/" + kname + "*")):
|
||||
pname = os.path.basename(pdir)
|
||||
if not os.path.exists(pdir + "/partition"):
|
||||
continue
|
||||
pnode = lsblk_map.get(pname, {})
|
||||
psect = read_int(pdir + "/size")
|
||||
disk["partitions"].append(
|
||||
{
|
||||
"kname": pname,
|
||||
"path": pnode.get("path") or "/dev/" + pname,
|
||||
"size_bytes": (psect * 512) if psect is not None else pnode.get("size"),
|
||||
"fstype": pnode.get("fstype"),
|
||||
"partlabel": pnode.get("partlabel"),
|
||||
"mountpoints": [m for m in (pnode.get("mountpoints") or []) if m],
|
||||
}
|
||||
)
|
||||
disks.append(disk)
|
||||
|
||||
# iSCSI/FC devices are ATTACHED VOLUMES, not hardware in this box: on a k3s node they
|
||||
# are Longhorn replicas the node has logged into, they come and go with workloads, and
|
||||
# they have no drive health of their own. Keeping them in `disks` would (a) pad the
|
||||
# report with churn and (b) — far worse — let a Longhorn volume look like a "spare
|
||||
# device" that node_baseline could be pointed at and reformatted. Split, never dropped.
|
||||
attached = [d for d in disks if (d.get("transport") or "") in ("iscsi", "fc", "fcoe")]
|
||||
disks = [d for d in disks if d not in attached]
|
||||
|
||||
# Mounted filesystems with real free space, from /proc/self/mounts + statvfs. No
|
||||
# `df` fork, and no root needed.
|
||||
mounts = []
|
||||
raw_mounts = read_text("/proc/self/mounts") or ""
|
||||
seen = set()
|
||||
for line in raw_mounts.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 3:
|
||||
continue
|
||||
source, target, fstype = fields[0], fields[1].replace("\\040", " "), fields[2]
|
||||
if fstype in PSEUDO_FS or not source.startswith("/dev/"):
|
||||
continue
|
||||
if target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
try:
|
||||
st = os.statvfs(target)
|
||||
except OSError as exc:
|
||||
UNREADABLE.append({"path": target, "reason": "statvfs failed: %s" % exc})
|
||||
continue
|
||||
total = st.f_blocks * st.f_frsize
|
||||
avail = st.f_bavail * st.f_frsize
|
||||
mounts.append(
|
||||
{
|
||||
"source": source,
|
||||
"mountpoint": target,
|
||||
"fstype": fstype,
|
||||
"options": fields[3] if len(fields) > 3 else None,
|
||||
"size_bytes": total,
|
||||
"avail_bytes": avail,
|
||||
"used_pct": round((total - avail) * 100.0 / total, 1) if total else None,
|
||||
"inodes_total": st.f_files,
|
||||
"inodes_avail": st.f_favail,
|
||||
"inodes_used_pct": round(
|
||||
(st.f_files - st.f_favail) * 100.0 / st.f_files, 1
|
||||
)
|
||||
if st.f_files
|
||||
else None,
|
||||
}
|
||||
)
|
||||
return {"disks": disks, "attached_volumes": attached, "mounts": mounts}
|
||||
|
||||
|
||||
# --- Network ------------------------------------------------------------------------
|
||||
def collect_network():
|
||||
interfaces = []
|
||||
for ndir in sorted(glob.glob("/sys/class/net/*")):
|
||||
name = os.path.basename(ndir)
|
||||
# Only physical/primary interfaces matter for node health. Virtual plumbing
|
||||
# (cilium_*, lxc*, veth, cni, flannel, docker/podman bridges, k8s tunnels) is
|
||||
# noise here and churns constantly.
|
||||
if re.match(r"^(lo|veth|cilium|lxc|cni|flannel|docker|podman|br-|kube|tunl|"
|
||||
r"vxlan|virbr|tailscale|wg|ip6tnl|sit|dummy|bond_|nodelocal)", name):
|
||||
continue
|
||||
stats = {}
|
||||
for sfile in sorted(glob.glob(ndir + "/statistics/*")):
|
||||
stats[os.path.basename(sfile)] = read_int(sfile)
|
||||
speed = read_int(ndir + "/speed") # -1 / unreadable when the link is down
|
||||
driver = None
|
||||
drvlink = ndir + "/device/driver"
|
||||
if os.path.islink(drvlink):
|
||||
driver = os.path.basename(os.path.realpath(drvlink))
|
||||
interfaces.append(
|
||||
{
|
||||
"name": name,
|
||||
"operstate": read_text(ndir + "/operstate"),
|
||||
"carrier": read_int(ndir + "/carrier"),
|
||||
"speed_mbps": speed if (speed is not None and speed > 0) else None,
|
||||
"duplex": read_text(ndir + "/duplex"),
|
||||
"mtu": read_int(ndir + "/mtu"),
|
||||
"mac": read_text(ndir + "/address"),
|
||||
"driver": driver,
|
||||
"virtual": not os.path.exists(ndir + "/device"),
|
||||
"statistics": {
|
||||
k: stats.get(k)
|
||||
for k in (
|
||||
"rx_packets", "tx_packets", "rx_bytes", "tx_bytes",
|
||||
"rx_errors", "tx_errors", "rx_dropped", "tx_dropped",
|
||||
"rx_crc_errors", "rx_frame_errors", "rx_over_errors",
|
||||
"tx_carrier_errors", "collisions",
|
||||
)
|
||||
if k in stats
|
||||
},
|
||||
}
|
||||
)
|
||||
# Resolver config is a known SPOF class on this project (a single upstream broke
|
||||
# containerd pulls and CoreDNS forwarding on dev). Cheap to read here, so the health
|
||||
# report can flag it on prod too.
|
||||
resolvers = collect_resolvers()
|
||||
return {
|
||||
"interfaces": interfaces,
|
||||
# List of REAL upstreams, or None when they could not be determined. NEVER the
|
||||
# loopback stub, and never [] standing in for "unknown".
|
||||
"nameservers": resolvers["nameservers"],
|
||||
"resolver_source": resolvers["source"],
|
||||
"resolver_stub": resolvers["stub"],
|
||||
"resolver_unavailable_reason": resolvers["unavailable_reason"],
|
||||
}
|
||||
|
||||
|
||||
# systemd-resolved's loopback listeners. 127.0.0.53 is the stub (the address written into
|
||||
# /etc/resolv.conf); 127.0.0.54 is the "bypass"/proxy listener. Neither is an UPSTREAM —
|
||||
# seeing one in /etc/resolv.conf means the real servers live inside resolved.
|
||||
RESOLVED_STUB_IPS = ("127.0.0.53", "127.0.0.54")
|
||||
|
||||
|
||||
def _resolved_upstreams():
|
||||
"""Real upstreams from systemd-resolved. Returns (servers, reason_unreadable).
|
||||
|
||||
`resolvectl dns` prints one line per scope ("Global:", "Link 2 (eth0): 1.2.3.4 ..."),
|
||||
which is far easier to parse reliably than the wrapped columns of `resolvectl status`.
|
||||
"""
|
||||
rc, out, err = run(["resolvectl", "dns", "--no-pager"])
|
||||
if rc is None:
|
||||
return None, (
|
||||
"/etc/resolv.conf holds only the systemd-resolved loopback stub, and the real "
|
||||
"upstreams behind it could not be read: resolvectl is not installed on this node"
|
||||
)
|
||||
if rc != 0:
|
||||
detail = (err.strip() or out.strip() or "no output").splitlines()[0]
|
||||
return None, (
|
||||
"/etc/resolv.conf holds only the systemd-resolved loopback stub, and "
|
||||
"`resolvectl dns` exited %s so the real upstreams could not be read: %s"
|
||||
% (rc, detail)
|
||||
)
|
||||
servers = []
|
||||
for line in out.splitlines():
|
||||
# Split on the FIRST colon only: everything after it is the server list, and IPv6
|
||||
# addresses contain colons of their own.
|
||||
_, sep, rest = line.partition(":")
|
||||
if not sep:
|
||||
continue
|
||||
for token in rest.split():
|
||||
if token not in servers:
|
||||
servers.append(token)
|
||||
if not servers:
|
||||
return None, (
|
||||
"/etc/resolv.conf holds only the systemd-resolved loopback stub and "
|
||||
"`resolvectl dns` reported NO upstream servers on any scope — resolved is "
|
||||
"running with an empty server list, so this node has no usable upstream"
|
||||
)
|
||||
return servers, None
|
||||
|
||||
|
||||
def collect_resolvers():
|
||||
"""The node's real DNS upstreams, plus how they were determined.
|
||||
|
||||
/etc/resolv.conf is NOT always the answer. Where systemd-resolved is in use (dev's
|
||||
nodes today) the file contains only resolved's loopback stub, 127.0.0.53 — reading it
|
||||
literally reports "1 nameserver" forever, which is a permanent FALSE single-upstream
|
||||
WARN that no amount of correct configuration can clear. When the stub is detected the
|
||||
real list is asked of resolved instead.
|
||||
|
||||
HONESTY DISCIPLINE, unchanged from the rest of this collector: if the stub is present
|
||||
and the real list cannot be read, "nameservers" is None with the reason recorded — NOT
|
||||
the stub address, NOT an empty list, and NOT a guess. An unknown must stay an unknown;
|
||||
the assess step turns it into a SKIP, never a PASS or a WARN.
|
||||
|
||||
LIMIT worth knowing: only systemd-resolved's stub is special-cased. Some OTHER local
|
||||
forwarder on 127.0.0.1 (dnsmasq, unbound) is reported exactly as it appears in
|
||||
resolv.conf, because nothing generic can enumerate an arbitrary forwarder's upstreams.
|
||||
"""
|
||||
path = "/etc/resolv.conf"
|
||||
# optional=False: a MISSING /etc/resolv.conf is itself a finding worth naming.
|
||||
resolv = read_text(path, optional=False)
|
||||
if resolv is None:
|
||||
return {
|
||||
"nameservers": None,
|
||||
"source": None,
|
||||
"stub": [],
|
||||
"unavailable_reason": "/etc/resolv.conf could not be read (see the unreadable list)",
|
||||
}
|
||||
listed = re.findall(r"^\s*nameserver\s+(\S+)", resolv, re.M)
|
||||
stub = [n for n in listed if n in RESOLVED_STUB_IPS]
|
||||
# Only when EVERY listed server is a stub is the file a pure indirection. A mixed file
|
||||
# (stub plus a real address) is reported as written — that is what the resolver library
|
||||
# will actually use.
|
||||
if listed and len(stub) == len(listed):
|
||||
servers, reason = _resolved_upstreams()
|
||||
if servers is None:
|
||||
UNREADABLE.append({"path": "resolvectl dns", "reason": reason})
|
||||
return {
|
||||
"nameservers": None,
|
||||
"source": None,
|
||||
"stub": listed,
|
||||
"unavailable_reason": reason,
|
||||
}
|
||||
return {
|
||||
"nameservers": servers,
|
||||
"source": "resolvectl dns (/etc/resolv.conf holds only the systemd-resolved "
|
||||
"stub %s)" % ", ".join(listed),
|
||||
"stub": listed,
|
||||
"unavailable_reason": None,
|
||||
}
|
||||
return {"nameservers": listed, "source": path, "stub": stub, "unavailable_reason": None}
|
||||
|
||||
|
||||
# --- OS / kernel / uptime -----------------------------------------------------------
|
||||
def collect_os():
|
||||
osrel = read_kv("/etc/os-release", sep="=") or {}
|
||||
uptime_raw = read_text("/proc/uptime")
|
||||
uptime = None
|
||||
if uptime_raw:
|
||||
try:
|
||||
uptime = float(uptime_raw.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
uptime = None
|
||||
return {
|
||||
"distribution": osrel.get("NAME"),
|
||||
"version": osrel.get("VERSION"),
|
||||
"version_id": osrel.get("VERSION_ID"),
|
||||
"pretty_name": osrel.get("PRETTY_NAME"),
|
||||
"kernel": (read_text("/proc/sys/kernel/osrelease")),
|
||||
"kernel_version_full": (read_text("/proc/version")),
|
||||
"hostname": (read_text("/proc/sys/kernel/hostname")),
|
||||
"uptime_seconds": uptime,
|
||||
"boot_time_epoch": (time.time() - uptime) if uptime else None,
|
||||
# Debian drops this file after a kernel/libc upgrade needing a reboot.
|
||||
"reboot_required": os.path.exists("/var/run/reboot-required")
|
||||
or os.path.exists("/run/reboot-required"),
|
||||
"dmesg_restricted": (read_int("/proc/sys/kernel/dmesg_restrict") == 1),
|
||||
}
|
||||
|
||||
|
||||
# --- Tooling presence ---------------------------------------------------------------
|
||||
# The role must NEVER install anything (a health check is read-only and side-effect
|
||||
# free — package installation is node_baseline's job, operator ruling 2026-08-31). So
|
||||
# we only DETECT, and the role skips the SMART checks with an accurate reason.
|
||||
def collect_tools():
|
||||
tools = {}
|
||||
for tool in ("smartctl", "nvme", "ethtool", "dmidecode", "lsblk", "lscpu"):
|
||||
tools[tool] = shutil.which(tool) or shutil.which(tool, path="/usr/sbin:/sbin:/usr/bin:/bin")
|
||||
return tools
|
||||
|
||||
|
||||
def main():
|
||||
doc = {
|
||||
"schema": SCHEMA,
|
||||
"collected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"collector_uid": os.geteuid(),
|
||||
"os": collect_os(),
|
||||
"dmi": collect_dmi(),
|
||||
"cpu": collect_cpu(),
|
||||
"memory": collect_memory(),
|
||||
"thermal": collect_thermal(),
|
||||
"pressure": collect_pressure(),
|
||||
"storage": collect_storage(),
|
||||
"network": collect_network(),
|
||||
"tools": collect_tools(),
|
||||
}
|
||||
doc["unreadable"] = UNREADABLE
|
||||
json.dump(doc, sys.stdout, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,676 @@
|
||||
---
|
||||
# Turn raw facts into explicit, named checks. Every check is one of:
|
||||
#
|
||||
# PASS measured, and within threshold
|
||||
# WARN measured, outside the warn threshold — worth a human look
|
||||
# FAIL measured, outside the fail threshold — do not put production state here
|
||||
# SKIP NOT measured, with the reason attached (missing root, missing tool, sensor not
|
||||
# present on this hardware). A SKIP is an honest "unknown" and is NEVER rendered
|
||||
# as a pass — this repo's discipline is absolute on that point.
|
||||
# INFO measured, no pass/fail semantics (inventory: firmware, uptime, CPU scaling)
|
||||
#
|
||||
# All thresholds come from node_health_thresholds (defaults/main.yml) so retuning never
|
||||
# means editing logic here.
|
||||
|
||||
- name: Derive intermediate values used by several checks
|
||||
ansible.builtin.set_fact:
|
||||
# Every temperature the box exposes, from both thermal zones and hwmon chips.
|
||||
# `select('number')` drops any sensor that returned null (unreadable/absent).
|
||||
node_health_temps_c: >-
|
||||
{{ ((node_health_facts.thermal.zones | map(attribute='temp_c') | list)
|
||||
+ (node_health_facts.thermal.hwmon | map(attribute='temps') | flatten
|
||||
| map(attribute='temp_c') | list)) | select('number') | list }}
|
||||
# Filesystems, worst-first by percentage used / inodes used.
|
||||
node_health_mounts_by_use: >-
|
||||
{{ node_health_facts.storage.mounts | selectattr('used_pct', 'number')
|
||||
| sort(attribute='used_pct') | reverse | list }}
|
||||
node_health_mounts_by_inode: >-
|
||||
{{ node_health_facts.storage.mounts | selectattr('inodes_used_pct', 'number')
|
||||
| sort(attribute='inodes_used_pct') | reverse | list }}
|
||||
# Physical NICs that are actually up. Virtual plumbing was already excluded by the
|
||||
# collector; `virtual` here catches bonds/VLANs the regex let through.
|
||||
node_health_nics_up: >-
|
||||
{{ node_health_facts.network.interfaces
|
||||
| selectattr('operstate', 'equalto', 'up') | list }}
|
||||
# Devices that could actually BECOME a dedicated Longhorn or etcd volume: no
|
||||
# partitions, no whole-device filesystem, nothing mounted from them. All three
|
||||
# conditions matter — node_baseline formats the data disks with no partition table at
|
||||
# all (mkfs straight onto /dev/vdb, mounted by LABEL), so "no partitions" alone would
|
||||
# nominate a node's live Longhorn disk for reformatting. iSCSI/FC volumes were already
|
||||
# split out by the collector for the same reason. On a single-disk node this list is
|
||||
# empty — precisely the finding that blocks PROD-PROVISIONING.md §2.
|
||||
node_health_free_devices: >-
|
||||
{{ node_health_facts.storage.disks
|
||||
| selectattr('partitions', 'equalto', [])
|
||||
| selectattr('fstype', 'none')
|
||||
| selectattr('mountpoints', 'equalto', [])
|
||||
| list }}
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Assess — privilege escalation (gates every root-only check below)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'access.privilege',
|
||||
'title': 'Privilege escalation (become) for root-only checks',
|
||||
'status': ('INFO' if node_health_root_available | bool else 'SKIP'),
|
||||
'detail': node_health_root_skip_reason }] }}"
|
||||
|
||||
# --- Storage ------------------------------------------------------------------------
|
||||
# SMART VERDICTS — READ defaults/main.yml's SMART threshold block BEFORE EDITING THIS.
|
||||
# Short version: this check judges a drive on the signals smartctl(8) itself defines as
|
||||
# vendor-independent (the drive's own overall-health verdict; NORMALISED value vs the
|
||||
# MANUFACTURER'S OWN threshold, via smartctl's `when_failed`; and the ATA-standard error
|
||||
# counters 197/198/187/199 whose raw meaning is a plain event count everywhere, plus NVMe's
|
||||
# structured health log). Vendor raw counts are reported with their numbers and can raise a
|
||||
# WARN, but they can never produce a FAIL — because their units are vendor-defined and
|
||||
# reading one as if it were a portable sector count is exactly the defect this replaces.
|
||||
- name: Assess — SMART per physical disk
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'storage.smart.' ~ item.kname,
|
||||
'title': 'SMART health — ' ~ item.path ~ ' (' ~ (item.model | default('unknown model')) ~ ')',
|
||||
'status': smart_status,
|
||||
'detail': smart_detail }] }}"
|
||||
loop: "{{ node_health_facts.storage.disks }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
disk_smart: "{{ (node_health_smart | selectattr('path', 'equalto', item.path) | list | first) | default({}) }}"
|
||||
|
||||
# --- Sub-verdicts. Each is SKIP unless the number behind it was actually read; a SKIP is
|
||||
# an honest "unknown" and never contributes a PASS. ------------------------------------
|
||||
|
||||
# 1. The drive's own overall-health self-assessment. Vendor-independent by construction.
|
||||
s_health: >-
|
||||
{{ 'SKIP' if (disk_smart.passed is not defined or disk_smart.passed is none)
|
||||
else ('PASS' if disk_smart.passed else 'FAIL') }}
|
||||
# 2. NVMe critical warning bitfield (spare below threshold, media in read-only, etc.).
|
||||
s_nvme_crit: >-
|
||||
{{ 'SKIP' if (disk_smart.nvme_critical_warning is not defined or disk_smart.nvme_critical_warning is not number)
|
||||
else ('PASS' if disk_smart.nvme_critical_warning | int == 0 else 'FAIL') }}
|
||||
# 3. THE portable per-attribute failure signal: normalised value at or below the
|
||||
# threshold the MANUFACTURER set for that attribute on that model. Pre-fail failing
|
||||
# now = FAIL. Old_age failing now, or anything that failed in the past, = WARN.
|
||||
s_thresh: >-
|
||||
{{ 'SKIP' if not (disk_smart.thresholds_present | default(false))
|
||||
else ('FAIL' if (disk_smart.failing_now_prefail | default([]) | length) > 0
|
||||
else ('WARN' if ((disk_smart.failing_now | default([]) | length) > 0
|
||||
or (disk_smart.failed_past | default([]) | length) > 0)
|
||||
else 'PASS')) }}
|
||||
# 4. ATA-standard hard-error counters. 197 pending; 198/187 + NVMe media_errors are
|
||||
# uncorrectable reads — data the drive could NOT return. Same meaning on every drive.
|
||||
s_pending: >-
|
||||
{{ 'SKIP' if (disk_smart.pending_sectors is not defined or disk_smart.pending_sectors is not number)
|
||||
else ('FAIL' if disk_smart.pending_sectors | int >= th.smart_pending_fail
|
||||
else ('WARN' if disk_smart.pending_sectors | int >= th.smart_pending_warn else 'PASS')) }}
|
||||
uncorr_values: >-
|
||||
{{ [disk_smart.offline_uncorrectable | default(none),
|
||||
disk_smart.reported_uncorrect | default(none),
|
||||
disk_smart.nvme_media_errors | default(none)] | select('number') | list }}
|
||||
s_uncorr: >-
|
||||
{{ 'SKIP' if (uncorr_values | length) == 0
|
||||
else ('FAIL' if (uncorr_values | max) >= th.smart_uncorrectable_fail
|
||||
else ('WARN' if (uncorr_values | max) >= th.smart_uncorrectable_warn else 'PASS')) }}
|
||||
# 5. 199 CRC — SATA link integrity (cable/backplane), not media. WARN only: the counter
|
||||
# never resets, so one historic bad seating must not condemn the node forever.
|
||||
s_crc: >-
|
||||
{{ 'SKIP' if (disk_smart.crc_errors is not defined or disk_smart.crc_errors is not number)
|
||||
else ('WARN' if disk_smart.crc_errors | int >= th.smart_crc_warn else 'PASS') }}
|
||||
# 6. Wear, from the NORMALISED lifetime indicator — a percentage by definition.
|
||||
life_rem: "{{ disk_smart.lifetime_remaining_pct | default(none) }}"
|
||||
life_used: "{{ (100 - (life_rem | int)) if (life_rem is number) else none }}"
|
||||
s_life: >-
|
||||
{{ 'SKIP' if (life_rem is not number)
|
||||
else ('FAIL' if life_rem | int <= th.smart_lifetime_remaining_fail_pct
|
||||
else ('WARN' if (life_rem | int <= th.smart_lifetime_remaining_warn_pct
|
||||
or life_used | int >= th.smart_life_used_pct_warn)
|
||||
else 'PASS')) }}
|
||||
# 7. Reallocation ACTIVITY. Portable evidence first (196 event count, and the grown
|
||||
# bad-block counter smartctl's drivedb named for this model). Only if neither exists
|
||||
# do we fall back to attribute 5's raw — and then ONLY to say "nonzero, so something
|
||||
# was remapped", which is portable, never to interpret its magnitude, which is not.
|
||||
remap_evidence: >-
|
||||
{{ [disk_smart.realloc_events | default(none),
|
||||
disk_smart.grown_bad_blocks | default(none)] | select('number') | list }}
|
||||
realloc_raw: "{{ (disk_smart.realloc_attr | default({}, true)).get('raw', {}).get('value', none) }}"
|
||||
s_remap: >-
|
||||
{{ ('WARN' if (remap_evidence | max) >= th.smart_realloc_events_warn else 'PASS')
|
||||
if (remap_evidence | length) > 0
|
||||
else (('WARN' if realloc_raw | int > 0 else 'PASS') if realloc_raw is number else 'SKIP') }}
|
||||
# 8. Endurance stress — erase/program failures are NAND wearing out under the FTL.
|
||||
s_erase: >-
|
||||
{{ 'SKIP' if (disk_smart.erase_fail_count is not defined or disk_smart.erase_fail_count is not number)
|
||||
else ('WARN' if disk_smart.erase_fail_count | int >= th.smart_erase_fail_warn else 'PASS') }}
|
||||
s_program: >-
|
||||
{{ 'SKIP' if (disk_smart.program_fail_count is not defined or disk_smart.program_fail_count is not number)
|
||||
else ('WARN' if disk_smart.program_fail_count | int >= th.smart_program_fail_warn else 'PASS') }}
|
||||
# 9. Unexpected power loss. On a consumer SSD with no power-loss protection each one is
|
||||
# an opportunity for a torn or lost in-flight write — which is precisely the guarantee
|
||||
# etcd's fsync contract depends on. Directly relevant to PROD-PROVISIONING.md Section 2.
|
||||
s_ploss: >-
|
||||
{{ 'SKIP' if (disk_smart.unexpected_power_loss is not defined or disk_smart.unexpected_power_loss is not number)
|
||||
else ('WARN' if disk_smart.unexpected_power_loss | int >= th.smart_unexpected_power_loss_warn else 'PASS') }}
|
||||
s_temp: >-
|
||||
{{ 'SKIP' if (disk_smart.temperature_c is not defined or disk_smart.temperature_c is not number)
|
||||
else ('FAIL' if disk_smart.temperature_c | int >= th.smart_temp_fail_c
|
||||
else ('WARN' if disk_smart.temperature_c | int >= th.smart_temp_warn_c else 'PASS')) }}
|
||||
# Worst sub-verdict wins. `select('in', ...)` preserves the order of the left-hand
|
||||
# list, so the LAST survivor is the most severe present.
|
||||
smart_status: >-
|
||||
{{ (['PASS', 'WARN', 'FAIL'] | select('in', [s_health, s_nvme_crit, s_thresh, s_pending,
|
||||
s_uncorr, s_crc, s_life, s_remap, s_erase, s_program, s_ploss, s_temp]) | list | last)
|
||||
| default('SKIP', true) }}
|
||||
|
||||
# --- Detail. Every WARN/FAIL must arrive WITH the number that caused it; a verdict the
|
||||
# operator cannot check is not much better than a wrong one. -------------------------
|
||||
smart_findings: >-
|
||||
{%- set n = namespace(l=[]) -%}
|
||||
{%- if s_health == 'FAIL' -%}
|
||||
{%- set _ = n.l.append('the drive reports its OWN overall-health self-assessment as FAILED') -%}
|
||||
{%- endif -%}
|
||||
{%- if s_nvme_crit == 'FAIL' -%}
|
||||
{%- set _ = n.l.append('NVMe critical_warning=' ~ disk_smart.nvme_critical_warning ~ ' (non-zero)') -%}
|
||||
{%- endif -%}
|
||||
{%- for a in (disk_smart.failing_now_prefail | default([])) -%}
|
||||
{%- set _ = n.l.append('Pre-fail attribute ' ~ a.id ~ ' ' ~ a.name ~ ' is AT OR BELOW its manufacturer threshold NOW (value='
|
||||
~ a.value ~ ', thresh=' ~ a.thresh ~ ')') -%}
|
||||
{%- endfor -%}
|
||||
{%- for a in (disk_smart.failed_past | default([])) -%}
|
||||
{%- set _ = n.l.append('attribute ' ~ a.id ~ ' ' ~ a.name ~ ' crossed its manufacturer threshold IN THE PAST (worst='
|
||||
~ a.worst ~ ', thresh=' ~ a.thresh ~ ')') -%}
|
||||
{%- endfor -%}
|
||||
{%- if s_pending in ['WARN', 'FAIL'] -%}
|
||||
{%- set _ = n.l.append('current pending sectors (197)=' ~ disk_smart.pending_sectors) -%}
|
||||
{%- endif -%}
|
||||
{%- if s_uncorr in ['WARN', 'FAIL'] -%}
|
||||
{%- set _ = n.l.append('UNCORRECTABLE read errors — offline(198)=' ~ (disk_smart.offline_uncorrectable if disk_smart.offline_uncorrectable is number else 'n/a')
|
||||
~ ', reported(187)=' ~ (disk_smart.reported_uncorrect if disk_smart.reported_uncorrect is number else 'n/a')
|
||||
~ ', nvme media_errors=' ~ (disk_smart.nvme_media_errors if disk_smart.nvme_media_errors is number else 'n/a')) -%}
|
||||
{%- endif -%}
|
||||
{%- if s_crc == 'WARN' -%}
|
||||
{%- set _ = n.l.append('SATA link CRC errors (199)=' ~ disk_smart.crc_errors ~ ' — cable/backplane, not media') -%}
|
||||
{%- endif -%}
|
||||
{%- if s_life in ['WARN', 'FAIL'] -%}
|
||||
{%- set _ = n.l.append(life_used ~ '% of rated life CONSUMED (' ~ life_rem
|
||||
~ '% remaining, from the normalised wear indicator)') -%}
|
||||
{%- endif -%}
|
||||
{%- if s_remap == 'WARN' and (remap_evidence | length) > 0 -%}
|
||||
{%- set _ = n.l.append((remap_evidence | max) ~ ' grown bad block(s)/reallocation event(s)'
|
||||
~ (' [196 events=' ~ disk_smart.realloc_events ~ ']' if disk_smart.realloc_events is number else '')
|
||||
~ (' [grown failing blocks=' ~ disk_smart.grown_bad_blocks ~ ']' if disk_smart.grown_bad_blocks is number else '')) -%}
|
||||
{%- elif s_remap == 'WARN' -%}
|
||||
{%- set _ = n.l.append('the drive reports a non-zero reallocation attribute (5 raw=' ~ realloc_raw
|
||||
~ '); its UNIT is vendor-defined, so the count is not interpreted here') -%}
|
||||
{%- endif -%}
|
||||
{%- if s_erase == 'WARN' -%}
|
||||
{%- set _ = n.l.append('NAND erase failures=' ~ disk_smart.erase_fail_count) -%}
|
||||
{%- endif -%}
|
||||
{%- if s_program == 'WARN' -%}
|
||||
{%- set _ = n.l.append('NAND program failures=' ~ disk_smart.program_fail_count) -%}
|
||||
{%- endif -%}
|
||||
{%- if s_ploss == 'WARN' -%}
|
||||
{%- set _ = n.l.append('unexpected power losses=' ~ disk_smart.unexpected_power_loss
|
||||
~ ' — a consumer SSD has no power-loss protection, so each one risks a torn or lost in-flight write') -%}
|
||||
{%- endif -%}
|
||||
{%- if s_temp in ['WARN', 'FAIL'] -%}
|
||||
{%- set _ = n.l.append('temperature=' ~ disk_smart.temperature_c ~ 'C') -%}
|
||||
{%- endif -%}
|
||||
{{ n.l | join('; ') }}
|
||||
# The raw value that started this. Shown ALWAYS when it is non-zero and contradicts the
|
||||
# normalised reading, explicitly labelled as vendor-encoded and explicitly excluded from
|
||||
# the verdict — so the next operator to see 61440 sees why it is not 61440 bad sectors.
|
||||
realloc_raw_note: >-
|
||||
{%- if realloc_raw is number and realloc_raw | int > 0 -%}
|
||||
{%- set a = disk_smart.realloc_attr -%}
|
||||
attribute 5 ({{ a.name }}) raw={{ realloc_raw }} normalised value={{ a.value }} vs thresh={{ a.thresh }}
|
||||
{%- if (remap_evidence | length) > 0 and (remap_evidence | max) > 0
|
||||
and realloc_raw | int != (remap_evidence | max) | int
|
||||
and realloc_raw | int % (remap_evidence | max) | int == 0 -%}
|
||||
{{ ' (= ' ~ (remap_evidence | max) ~ ' x ' ~ (realloc_raw | int // (remap_evidence | max) | int) ~ ')' }}
|
||||
{%- endif -%}
|
||||
. SMART raw values are VENDOR-DEFINED (smartctl(8): "entirely vendor-specific") and this
|
||||
one is NOT a portable bad-sector count — it is reported as context and is NOT used for
|
||||
the verdict. The normalised value/threshold pair beside it is the manufacturer's own
|
||||
trip point and IS.
|
||||
{%- endif -%}
|
||||
smart_detail: >-
|
||||
{{ ('not read — ' ~ node_health_root_skip_reason)
|
||||
if not (node_health_root_available | bool)
|
||||
else ('not read — smartmontools is not installed on this node; it ships with
|
||||
roles/node_baseline (node_baseline_packages), which has not been run here.
|
||||
This role never installs packages.')
|
||||
if not (node_health_facts.tools.smartctl is truthy)
|
||||
else ('smartctl ran but this device exposes no SMART data ('
|
||||
~ (disk_smart.messages | default([]) | join('; ')) ~ ') — normal for virtio/VM disks')
|
||||
if not (disk_smart.available | default(false))
|
||||
else ('overall-health=' ~ ('PASSED' if disk_smart.passed else 'FAILED')
|
||||
~ ', power_on_hours=' ~ (disk_smart.power_on_hours | default('?'))
|
||||
~ ', power_cycles=' ~ (disk_smart.power_cycles | default('?'))
|
||||
~ ', life_remaining=' ~ ((life_rem | string ~ '%') if life_rem is number else 'not reported')
|
||||
~ ', pending(197)=' ~ (disk_smart.pending_sectors if disk_smart.pending_sectors is number else 'n/a')
|
||||
~ ', offline_uncorrectable(198)=' ~ (disk_smart.offline_uncorrectable if disk_smart.offline_uncorrectable is number else 'n/a')
|
||||
~ ', reported_uncorrect(187)=' ~ (disk_smart.reported_uncorrect if disk_smart.reported_uncorrect is number else 'n/a')
|
||||
~ ', crc(199)=' ~ (disk_smart.crc_errors if disk_smart.crc_errors is number else 'n/a')
|
||||
~ (('. NO attribute is at or below its manufacturer threshold; closest is '
|
||||
~ disk_smart.prefail_closest.id ~ ' ' ~ disk_smart.prefail_closest.name
|
||||
~ ' value=' ~ disk_smart.prefail_closest.value
|
||||
~ ' vs thresh=' ~ disk_smart.prefail_closest.thresh)
|
||||
if (s_thresh == 'PASS' and (disk_smart.prefail_closest | default(none)) is mapping) else '')
|
||||
~ (('. FINDINGS: ' ~ smart_findings) if smart_findings | trim else '')
|
||||
~ (('. CONTEXT: ' ~ realloc_raw_note) if realloc_raw_note | trim else '')) }}
|
||||
|
||||
- name: Assess — kernel-visible I/O errors per disk (no root needed)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'storage.io_errors',
|
||||
'title': 'Kernel I/O error counters on physical disks (SCSI/ATA layer)',
|
||||
'status': io_status,
|
||||
'detail': io_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
# ioerr_cnt/iotmo_cnt are readable WITHOUT root. They are not a substitute for SMART
|
||||
# (no wear, no power-on hours, no reallocated sectors) but they are a real signal: a
|
||||
# non-zero count means the kernel actually saw failed or timed-out commands on that
|
||||
# device since boot.
|
||||
counted: "{{ node_health_facts.storage.disks | selectattr('scsi_counters', 'mapping') | list }}"
|
||||
err_total: >-
|
||||
{{ (counted | map(attribute='scsi_counters.ioerr_cnt') | select('number') | list | sum)
|
||||
+ (counted | map(attribute='scsi_counters.iotmo_cnt') | select('number') | list | sum) }}
|
||||
io_status: >-
|
||||
{{ 'SKIP' if (counted | length) == 0
|
||||
else ('PASS' if err_total | int == 0 else 'WARN') }}
|
||||
io_sum_done: "{{ counted | map(attribute='scsi_counters.iodone_cnt') | select('number') | list | sum }}"
|
||||
io_sum_err: "{{ counted | map(attribute='scsi_counters.ioerr_cnt') | select('number') | list | sum }}"
|
||||
io_sum_tmo: "{{ counted | map(attribute='scsi_counters.iotmo_cnt') | select('number') | list | sum }}"
|
||||
# Aggregate here; the STORAGE section of the summary prints the per-disk counters.
|
||||
io_detail: >-
|
||||
{{ 'no disk exposes SCSI/ATA error counters (virtio or NVMe-only node)'
|
||||
if (counted | length) == 0
|
||||
else ((counted | map(attribute='kname') | join(', ')) ~ ': completed='
|
||||
~ io_sum_done | string ~ ', errors=' ~ io_sum_err | string
|
||||
~ ', timeouts=' ~ io_sum_tmo | string
|
||||
~ ('. Non-zero errors are commands the kernel saw fail on that device
|
||||
since boot. They are NOT proof of media failure (a rejected or
|
||||
unsupported command during discovery counts too) and they cannot be
|
||||
attributed without dmesg, which needs root.'
|
||||
if err_total | int > 0 else '')) }}
|
||||
|
||||
- name: Assess — filesystem free space
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'storage.capacity',
|
||||
'title': 'Filesystem free space (worst mount)',
|
||||
'status': fs_status,
|
||||
'detail': fs_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
worst: "{{ (node_health_mounts_by_use | first) | default({}) }}"
|
||||
fs_status: >-
|
||||
{{ 'SKIP' if (node_health_mounts_by_use | length) == 0
|
||||
else ('FAIL' if worst.used_pct >= th.fs_used_pct_fail
|
||||
else ('WARN' if worst.used_pct >= th.fs_used_pct_warn else 'PASS')) }}
|
||||
fs_detail: >-
|
||||
{{ 'no block-backed filesystem found' if (node_health_mounts_by_use | length) == 0
|
||||
else (worst.mountpoint ~ ' ' ~ worst.used_pct | string ~ '% used ('
|
||||
~ (worst.avail_bytes | int / 1073741824) | round(1) | string ~ ' GiB free of '
|
||||
~ (worst.size_bytes | int / 1073741824) | round(1) | string ~ ' GiB)') }}
|
||||
|
||||
- name: Assess — filesystem inode headroom
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'storage.inodes',
|
||||
'title': 'Filesystem inode headroom (worst mount)',
|
||||
'status': in_status,
|
||||
'detail': in_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
worst: "{{ (node_health_mounts_by_inode | first) | default({}) }}"
|
||||
in_status: >-
|
||||
{{ 'SKIP' if (node_health_mounts_by_inode | length) == 0
|
||||
else ('WARN' if worst.inodes_used_pct >= th.inode_used_pct_warn else 'PASS') }}
|
||||
in_detail: >-
|
||||
{{ 'no filesystem reported inode usage' if (node_health_mounts_by_inode | length) == 0
|
||||
else (worst.mountpoint ~ ' ' ~ worst.inodes_used_pct | string ~ '% of inodes used') }}
|
||||
|
||||
- name: Assess — data-disk layout (feeds PROD-PROVISIONING.md §2)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'storage.layout',
|
||||
'title': 'Physical disks and spare block devices for Longhorn/etcd',
|
||||
'status': 'INFO',
|
||||
'detail': layout_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
disks: "{{ node_health_facts.storage.disks }}"
|
||||
attached: "{{ node_health_facts.storage.attached_volumes | default([]) }}"
|
||||
layout_detail: >-
|
||||
{{ (disks | length) | string ~ ' physical disk(s): '
|
||||
~ (disks | map(attribute='kname') | join(', ')) ~ '. '
|
||||
~ ((attached | length) | string ~ ' attached iSCSI/FC volume(s) excluded ('
|
||||
~ (attached | map(attribute='kname') | join(', ')) ~ '). '
|
||||
if (attached | length) > 0 else '')
|
||||
~ ((node_health_free_devices | length) | string ~ ' device(s) with no partitions ('
|
||||
~ (node_health_free_devices | map(attribute='kname') | join(', ')) ~ ')'
|
||||
if (node_health_free_devices | length) > 0
|
||||
else 'NO unpartitioned spare device — node_baseline_longhorn_device /
|
||||
node_baseline_etcd_device have nothing to claim without repartitioning
|
||||
or adding a drive') }}
|
||||
|
||||
# --- Memory -------------------------------------------------------------------------
|
||||
- name: Assess — memory headroom
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'memory.available',
|
||||
'title': 'Available memory',
|
||||
'status': mem_status,
|
||||
'detail': mem_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
m: "{{ node_health_facts.memory }}"
|
||||
avail_pct: >-
|
||||
{{ ((m.mem_available_kb | int) * 100.0 / (m.mem_total_kb | int)) | round(1)
|
||||
if (m.mem_total_kb is number and m.mem_available_kb is number and m.mem_total_kb | int > 0)
|
||||
else none }}
|
||||
mem_status: >-
|
||||
{{ 'SKIP' if avail_pct is not number
|
||||
else ('FAIL' if avail_pct | float <= th.mem_available_pct_fail
|
||||
else ('WARN' if avail_pct | float <= th.mem_available_pct_warn else 'PASS')) }}
|
||||
mem_detail: >-
|
||||
{{ '/proc/meminfo unreadable' if avail_pct is not number
|
||||
else ((m.mem_total_kb | int / 1048576) | round(1) | string ~ ' GiB total, '
|
||||
~ (m.mem_available_kb | int / 1048576) | round(1) | string ~ ' GiB available ('
|
||||
~ avail_pct | string ~ '%)') }}
|
||||
|
||||
- name: Assess — swap (kubelet requires it off)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'memory.swap',
|
||||
'title': 'Swap disabled (kubelet prerequisite)',
|
||||
'status': swap_status,
|
||||
'detail': swap_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
m: "{{ node_health_facts.memory }}"
|
||||
swap_status: >-
|
||||
{{ 'SKIP' if m.swap_total_kb is not number
|
||||
else ('PASS' if m.swap_total_kb | int == 0 else 'WARN') }}
|
||||
swap_detail: >-
|
||||
{{ 'swap state unreadable' if m.swap_total_kb is not number
|
||||
else ('no swap active' if m.swap_total_kb | int == 0
|
||||
else ((m.swap_total_kb | int / 1048576) | round(2) | string
|
||||
~ ' GiB swap active — roles/node_baseline runs swapoff and neutralises
|
||||
the fstab entry; this node has not been baselined yet')) }}
|
||||
|
||||
- name: Assess — ECC / EDAC correctable errors
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'memory.ecc',
|
||||
'title': 'ECC memory errors (EDAC correctable/uncorrectable counters)',
|
||||
'status': ecc_status,
|
||||
'detail': ecc_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
e: "{{ node_health_facts.memory.edac }}"
|
||||
ecc_status: >-
|
||||
{{ 'SKIP' if not (e.controllers_present | bool)
|
||||
else ('FAIL' if (e.ue_total | int) > 0
|
||||
else ('WARN' if (e.ce_total | int) > 0 else 'PASS')) }}
|
||||
ecc_detail: >-
|
||||
{{ ('no EDAC memory controller is bound (' ~ ('/sys/devices/system/edac/mc exists but is empty'
|
||||
if e.sysfs_present | bool else 'no EDAC sysfs at all')
|
||||
~ ') — this platform does not report ECC status. Non-ECC consumer RAM or a VM;
|
||||
"no errors" CANNOT be confirmed on this hardware.')
|
||||
if not (e.controllers_present | bool)
|
||||
else ((e.controllers | length) | string ~ ' controller(s), correctable='
|
||||
~ e.ce_total | string ~ ', uncorrectable=' ~ e.ue_total | string) }}
|
||||
|
||||
# --- CPU ----------------------------------------------------------------------------
|
||||
- name: Assess — CPU thermal throttling
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'cpu.throttle',
|
||||
'title': 'CPU thermal throttling since boot',
|
||||
'status': thr_status,
|
||||
'detail': thr_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
t: "{{ node_health_facts.cpu.throttle }}"
|
||||
thr_status: >-
|
||||
{{ 'SKIP' if t is none
|
||||
else ('PASS' if ((t.core_throttle_count_total | int) + (t.package_throttle_count | int)) == 0
|
||||
else 'WARN') }}
|
||||
thr_detail: >-
|
||||
{{ 'no thermal_throttle counters (VM guest or unsupported CPU)' if t is none
|
||||
else ('core throttle events=' ~ t.core_throttle_count_total | string
|
||||
~ ' (' ~ t.core_throttle_total_time_ms | string ~ ' ms), package events='
|
||||
~ t.package_throttle_count | string) }}
|
||||
|
||||
- name: Assess — CPU inventory and frequency scaling
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'cpu.inventory',
|
||||
'title': 'CPU model, topology and frequency scaling',
|
||||
'status': 'INFO',
|
||||
'detail': cpu_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
c: "{{ node_health_facts.cpu }}"
|
||||
cpu_detail: >-
|
||||
{{ (c.model | default('unknown')) ~ ' — ' ~ (c.cpus | default('?')) ~ ' logical CPU(s), '
|
||||
~ (c.sockets | default('?')) ~ ' socket(s) x ' ~ (c.cores_per_socket | default('?'))
|
||||
~ ' core(s) x ' ~ (c.threads_per_core | default('?')) ~ ' thread(s); load '
|
||||
~ (c.loadavg | default([]) | join(' ')) ~ '; '
|
||||
~ ('governor=' ~ c.scaling.governor ~ ' driver=' ~ c.scaling.driver
|
||||
~ ' cur=' ~ ((c.scaling.cur_khz | int) / 1000) | round | int | string ~ 'MHz'
|
||||
~ ' max=' ~ ((c.scaling.max_khz | int) / 1000) | round | int | string ~ 'MHz'
|
||||
if c.scaling is mapping else 'no cpufreq scaling exposed') }}
|
||||
|
||||
- name: Assess — hardware virtualisation (KubeVirt prerequisite, R-PLAT-03)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'cpu.virtualisation',
|
||||
'title': 'Hardware virtualisation available (VT-x/AMD-V + /dev/kvm)',
|
||||
'status': virt_status,
|
||||
'detail': virt_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
c: "{{ node_health_facts.cpu }}"
|
||||
virt_status: >-
|
||||
{{ 'PASS' if (c.hardware_virt_flag | bool and c.kvm_device | bool) else 'WARN' }}
|
||||
virt_detail: >-
|
||||
{{ 'vmx/svm flag=' ~ c.hardware_virt_flag | string ~ ', /dev/kvm='
|
||||
~ c.kvm_device | string
|
||||
~ (', lscpu Virtualization=' ~ c.virtualization if c.virtualization else '')
|
||||
~ (', running as a guest of ' ~ c.hypervisor_vendor if c.is_virtual_guest | bool else '') }}
|
||||
|
||||
# --- Thermals -----------------------------------------------------------------------
|
||||
- name: Assess — temperatures
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'thermal.max',
|
||||
'title': 'Hottest reported sensor',
|
||||
'status': temp_status,
|
||||
'detail': temp_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
hottest: "{{ (node_health_temps_c | max) if (node_health_temps_c | length) > 0 else none }}"
|
||||
temp_status: >-
|
||||
{{ 'SKIP' if hottest is not number
|
||||
else ('FAIL' if hottest | float >= th.temp_fail_c
|
||||
else ('WARN' if hottest | float >= th.temp_warn_c else 'PASS')) }}
|
||||
temp_detail: >-
|
||||
{{ 'no thermal sensor exposed (typical in a VM)' if hottest is not number
|
||||
else (hottest | string ~ 'C hottest of ' ~ (node_health_temps_c | length) | string
|
||||
~ ' sensors; zones: '
|
||||
~ (node_health_facts.thermal.zones
|
||||
| map(attribute='type') | join(', ')) ) }}
|
||||
|
||||
# --- Pressure-stall (PSI) -----------------------------------------------------------
|
||||
- name: Assess — pressure stall (CPU, memory, I/O)
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'pressure.' ~ item,
|
||||
'title': 'PSI ' ~ item ~ ' — some avg60 (% of wall time stalled)',
|
||||
'status': psi_status,
|
||||
'detail': psi_detail }] }}"
|
||||
loop: [cpu, memory, io]
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
psi: "{{ node_health_facts.pressure[item] | default(none) }}"
|
||||
avg60: "{{ psi.some.avg60 | default(none) if psi is mapping else none }}"
|
||||
psi_status: >-
|
||||
{{ 'SKIP' if avg60 is not number
|
||||
else ('FAIL' if avg60 | float >= th.psi_some_avg60_fail
|
||||
else ('WARN' if avg60 | float >= th.psi_some_avg60_warn else 'PASS')) }}
|
||||
psi_detail: >-
|
||||
{{ '/proc/pressure/' ~ item ~ ' unavailable (kernel without CONFIG_PSI, or psi=0)'
|
||||
if avg60 is not number
|
||||
else ('some avg10=' ~ psi.some.avg10 | string ~ ' avg60=' ~ psi.some.avg60 | string
|
||||
~ ' avg300=' ~ psi.some.avg300 | string
|
||||
~ (' | full avg60=' ~ psi.full.avg60 | string if psi.full is defined else '')) }}
|
||||
|
||||
# --- Network ------------------------------------------------------------------------
|
||||
- name: Assess — link state and speed
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'network.link',
|
||||
'title': 'Physical NIC link state and negotiated speed',
|
||||
'status': link_status,
|
||||
'detail': link_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
th: "{{ node_health_thresholds }}"
|
||||
up: "{{ node_health_nics_up }}"
|
||||
slow: >-
|
||||
{{ up | selectattr('speed_mbps', 'number')
|
||||
| selectattr('speed_mbps', 'lt', th.nic_min_speed_mbps | int) | list }}
|
||||
link_status: >-
|
||||
{{ 'FAIL' if (up | length) == 0
|
||||
else ('WARN' if (slow | length) > 0 else 'PASS') }}
|
||||
link_detail: >-
|
||||
{{ 'no physical interface is up' if (up | length) == 0
|
||||
else (up | map(attribute='name') | zip(up | map(attribute='speed_mbps'))
|
||||
| map('join', '=') | join('Mb/s, ') ~ 'Mb/s'
|
||||
~ ' (driver ' ~ (up | map(attribute='driver') | join(', ')) ~ ')') }}
|
||||
|
||||
- name: Assess — link error counters
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'network.errors',
|
||||
'title': 'NIC error counters since boot (rx/tx errors, CRC, carrier)',
|
||||
'status': ('PASS' if err_total | int == 0 else 'WARN'),
|
||||
'detail': err_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
up: "{{ node_health_nics_up }}"
|
||||
err_keys: [rx_errors, tx_errors, rx_crc_errors, rx_frame_errors, rx_over_errors, tx_carrier_errors]
|
||||
err_total: >-
|
||||
{{ up | map(attribute='statistics') | map('dict2items') | flatten
|
||||
| selectattr('key', 'in', err_keys) | map(attribute='value')
|
||||
| select('number') | list | sum }}
|
||||
drop_total: >-
|
||||
{{ up | map(attribute='statistics') | map('dict2items') | flatten
|
||||
| selectattr('key', 'in', ['rx_dropped', 'tx_dropped']) | map(attribute='value')
|
||||
| select('number') | list | sum }}
|
||||
err_detail: >-
|
||||
{{ err_total | string ~ ' error(s), ' ~ drop_total | string ~ ' drop(s) across '
|
||||
~ (up | map(attribute='name') | join(', ')) }}
|
||||
|
||||
# The collector reports the node's REAL upstreams: where /etc/resolv.conf holds only
|
||||
# systemd-resolved's 127.0.0.53 stub (dev's shape) it asks resolved for the servers behind
|
||||
# it, because counting the stub would be a permanent FALSE single-upstream WARN that no
|
||||
# correct configuration could ever clear. When that lookup fails it reports null WITH a
|
||||
# reason rather than a number — so the check SKIPs. A skip is honest; a WARN derived from
|
||||
# an unknown is a fabricated verdict, and a PASS would be worse.
|
||||
- name: Assess — DNS resolver redundancy
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'network.resolvers',
|
||||
'title': 'Configured DNS upstreams (single upstream is a known SPOF here)',
|
||||
'status': ('SKIP' if node_health_facts.network.nameservers is none
|
||||
else ('PASS' if (ns4 | length) >= (node_health_thresholds.min_nameservers | int)
|
||||
else 'WARN')),
|
||||
'detail': ns_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
# `| default([], true)` only ever fires on the null (unreadable) path, where the
|
||||
# counts below are unused because the status is already SKIP.
|
||||
ns: "{{ node_health_facts.network.nameservers | default([], true) }}"
|
||||
# Counted PER FAMILY on purpose. The outage this check exists for was a single IPv4
|
||||
# upstream; IPv6 upstreams do not cover for it unless the node actually has working
|
||||
# IPv6 egress, which this role cannot prove from sysfs alone. So the verdict keys off
|
||||
# the IPv4 count and the detail shows the full picture.
|
||||
ns4: "{{ ns | select('search', '^[0-9.]+$') | list }}"
|
||||
ns6: "{{ ns | reject('search', '^[0-9.]+$') | list }}"
|
||||
# Where the numbers came from — resolv.conf directly, or resolved behind the stub.
|
||||
ns_src: "{{ node_health_facts.network.resolver_source | default('', true) }}"
|
||||
ns_detail: >-
|
||||
{{ ('not read — ' ~ (node_health_facts.network.resolver_unavailable_reason
|
||||
| default('the collector recorded no reason', true))
|
||||
~ '. The upstream count is UNKNOWN here — not zero, and not one.')
|
||||
if node_health_facts.network.nameservers is none
|
||||
else ((ns | length) | string ~ ' nameserver(s) — IPv4: '
|
||||
~ (ns4 | join(', ') | default('none', true))
|
||||
~ '; IPv6: ' ~ (ns6 | join(', ') | default('none', true))
|
||||
~ (' (read via ' ~ ns_src ~ ')' if ns_src else '')
|
||||
~ ('. Only ' ~ (ns4 | length) | string ~ ' IPv4 upstream: a single IPv4 upstream
|
||||
already broke containerd image pulls and CoreDNS forwarding on dev. The fix
|
||||
is the `dns`-tagged block in roles/node_baseline — set this environment
|
||||
node_baseline_dns_servers (role defaults for dev, group_vars/k3s_prod for
|
||||
prod) and re-run node-baseline.yml --tags dns. IPv6 upstreams only compensate
|
||||
if the node has working IPv6 egress, which this check cannot verify.'
|
||||
if (ns4 | length) < (node_health_thresholds.min_nameservers | int) else '')) }}
|
||||
|
||||
# --- OS / firmware ------------------------------------------------------------------
|
||||
- name: Assess — pending reboot
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'os.reboot_required',
|
||||
'title': 'Pending reboot flag',
|
||||
'status': ('WARN' if node_health_facts.os.reboot_required | bool else 'PASS'),
|
||||
'detail': ('/run/reboot-required present — a package upgrade needs a restart'
|
||||
if node_health_facts.os.reboot_required | bool
|
||||
else 'no reboot flagged') }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Assess — OS, kernel and uptime
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'os.inventory',
|
||||
'title': 'OS, kernel and uptime',
|
||||
'status': 'INFO',
|
||||
'detail': os_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
o: "{{ node_health_facts.os }}"
|
||||
os_detail: >-
|
||||
{{ (o.pretty_name | default('unknown OS')) ~ ', kernel ' ~ (o.kernel | default('?'))
|
||||
~ ', up ' ~ ((o.uptime_seconds | float / 86400) | round(2) | string) ~ ' day(s)' }}
|
||||
|
||||
- name: Assess — firmware / DMI inventory
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: "{{ node_health_checks + [{
|
||||
'id': 'firmware.inventory',
|
||||
'title': 'Chassis, board and BIOS firmware',
|
||||
'status': ('INFO' if node_health_facts.dmi.present | bool else 'SKIP'),
|
||||
'detail': dmi_detail }] }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
d: "{{ node_health_facts.dmi }}"
|
||||
dmi_detail: >-
|
||||
{{ 'no DMI table exposed (typical for some VMs/containers)' if not (d.present | bool)
|
||||
else ((d.sys_vendor | default('?')) ~ ' ' ~ (d.product_name | default('?'))
|
||||
~ ' / board ' ~ (d.board_name | default('?'))
|
||||
~ ' / BIOS ' ~ (d.bios_vendor | default('?')) ~ ' '
|
||||
~ (d.bios_version | default('?')) ~ ' (' ~ (d.bios_date | default('?')) ~ ')'
|
||||
~ (' — serial numbers not read (root-only DMI fields)'
|
||||
if d.product_serial is none else '')) }}
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
# SMART — the ONLY part of this role that needs root, and the highest-value part.
|
||||
#
|
||||
# Reached only when probe_privilege.yml proved escalation works AND the collector found a
|
||||
# smartctl binary. Both conditions are checked by the caller (main.yml); if either is
|
||||
# false the SMART checks SKIP with the precise reason rather than silently passing.
|
||||
#
|
||||
# smartmontools is NOT installed from here. Package installation is node_baseline's job
|
||||
# (node_baseline_packages carries smartmontools) — operator ruling 2026-08-31, the same
|
||||
# click-ops-never principle as the rest of the repo. A health check must be read-only and
|
||||
# side-effect-free, so this role only ever DETECTS the tool.
|
||||
#
|
||||
# `--nocheck=standby` (defaults/main.yml) means a sleeping disk is never spun up just to
|
||||
# be measured. smartctl's exit status is a BITMASK (bit 0 = usage error, bit 1 = open
|
||||
# failed, bit 3 = disk failing, ...), not a simple success/failure, so `failed_when: false`
|
||||
# is correct here: a non-zero exit still carries a useful JSON payload, and bit 3 in
|
||||
# particular is a finding, not an error to abort on.
|
||||
|
||||
- name: Read SMART data for each physical disk
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ node_health_facts.tools.smartctl }} {{ node_health_smartctl_args }} {{ item.path }}"
|
||||
become: true
|
||||
vars:
|
||||
ansible_become: true
|
||||
loop: "{{ node_health_facts.storage.disks }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
register: node_health_smart_raw
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Parse SMART payloads into per-disk facts
|
||||
ansible.builtin.set_fact:
|
||||
node_health_smart: "{{ node_health_smart + [smart_disk] }}"
|
||||
loop: "{{ node_health_smart_raw.results | default([]) }}"
|
||||
loop_control:
|
||||
loop_var: smart_result
|
||||
label: "{{ smart_result.item.path }}"
|
||||
vars:
|
||||
ansible_become: false
|
||||
# An unopenable device (virtio in a VM, a USB bridge without passthrough) yields no
|
||||
# stdout at all — default to an empty object so parsing never explodes on a VM.
|
||||
smart_json: "{{ (smart_result.stdout | default('') | trim) | default('{}', true) | from_json }}"
|
||||
smart_attrs: "{{ smart_json.ata_smart_attributes.table | default([]) }}"
|
||||
smart_nvme: "{{ smart_json.nvme_smart_health_information_log | default({}) }}"
|
||||
|
||||
# --- VENDOR-INDEPENDENT FAILURE SIGNAL: normalised value vs the drive's own threshold.
|
||||
# smartctl computes this for us and emits it as `when_failed`, per smartctl(8): "If the
|
||||
# Attribute's current Normalized value is less than or equal to the threshold value, then
|
||||
# the WHEN_FAILED column will display FAILING_NOW. If not, but the worst recorded value is
|
||||
# less than or equal to the threshold value, then this column will display In_the_past. If
|
||||
# the WHEN_FAILED column has no entry ... then this Attribute is OK now (not failing) and
|
||||
# has also never failed in the past." In JSON those become "now" / "past" / "". We match
|
||||
# case-insensitively on 'past' rather than on an exact token so the same code reads either
|
||||
# the JSON spelling or the plain-text one if a future smartctl changes it.
|
||||
#
|
||||
# This is the ONLY comparison in this role that can condemn a drive on an attribute the
|
||||
# role does not itself understand, and that is the point: the manufacturer picked the
|
||||
# threshold, for that attribute, on that model. It needs no per-vendor knowledge from us.
|
||||
smart_failed_attrs: >-
|
||||
{{ smart_attrs | selectattr('when_failed', 'defined')
|
||||
| rejectattr('when_failed', 'equalto', '') | list }}
|
||||
smart_failed_past: "{{ smart_failed_attrs | selectattr('when_failed', 'search', '(?i)past') | list }}"
|
||||
smart_failing_now: "{{ smart_failed_attrs | rejectattr('when_failed', 'search', '(?i)past') | list }}"
|
||||
# Of the failing ones, which are Pre-fail (imminent failure) vs Old_age (design life
|
||||
# exceeded)? smartctl decodes that flag from the drive too, so this stays vendor-neutral.
|
||||
smart_failing_now_prefail: "{{ smart_failing_now | selectattr('flags.prefailure', 'defined') | selectattr('flags.prefailure') | list }}"
|
||||
# How close is the drive to ANY of its own trip points? The smallest (value - thresh)
|
||||
# across all Pre-fail attributes that actually carry a threshold (thresh 0 = "cannot
|
||||
# trip", per ATA, so it is excluded rather than counted as a margin of `value`).
|
||||
# Reported as context; a shrinking margin is the early warning the raw values cannot give.
|
||||
smart_prefail_margin_json: >-
|
||||
{%- set ns = namespace(best=none) -%}
|
||||
{%- for a in smart_attrs -%}
|
||||
{%- if (a.flags.prefailure | default(false)) and ((a.thresh | default(0)) | int) > 0 -%}
|
||||
{%- set m = ((a.value | default(0)) | int) - ((a.thresh) | int) -%}
|
||||
{%- if ns.best is none or m < ns.best.margin -%}
|
||||
{%- set ns.best = {'id': a.id, 'name': a.name, 'value': (a.value | int),
|
||||
'worst': ((a.worst | default(a.value)) | int),
|
||||
'thresh': (a.thresh | int), 'margin': m} -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ ns.best | to_json }}
|
||||
|
||||
# --- Normalised ("value") wear indicators, in vendor-preference order. Micron/Crucial use
|
||||
# 202 Perc_Rated_Life_Used; many others use 231 SSD_Life_Left or 233. All three express
|
||||
# the SAME thing in the normalised field — a percentage of rated life REMAINING — which is
|
||||
# exactly why the normalised field is usable across vendors where their raws are not.
|
||||
smart_life_202: "{{ smart_attrs | selectattr('id', 'equalto', 202) | map(attribute='value') | first | default(none) }}"
|
||||
smart_life_231: "{{ smart_attrs | selectattr('id', 'equalto', 231) | map(attribute='value') | first | default(none) }}"
|
||||
smart_life_233: "{{ smart_attrs | selectattr('id', 'equalto', 233) | map(attribute='value') | first | default(none) }}"
|
||||
|
||||
# --- VENDOR CONTEXT. Everything below is collected and printed but NEVER produces a FAIL.
|
||||
# Attribute 5 is kept in full — id, drive's own name, normalised value/worst/thresh AND
|
||||
# raw — precisely so the report can show the raw next to the normalised reading that
|
||||
# contradicts it, instead of silently dropping the number that started this.
|
||||
smart_attr5: "{{ (smart_attrs | selectattr('id', 'equalto', 5) | list | first) | default(none) }}"
|
||||
# 196 Reallocated_Event_Count — an ATA-standard *event* count (one tick per remap
|
||||
# operation), the portable stand-in for attribute 5's unportable raw.
|
||||
smart_realloc_events: "{{ smart_attrs | selectattr('id', 'equalto', 196) | map(attribute='raw.value') | first | default(none) }}"
|
||||
# Endurance counters matched by the NAME smartctl's drive database resolved for this
|
||||
# model, not by a bare vendor attribute ID. IDs 170-174 are in the vendor-specific range
|
||||
# and mean different things on different controllers; drivedb is smartmontools' own
|
||||
# per-(model, firmware) translation of them, so matching its resolved name borrows that
|
||||
# knowledge instead of hardcoding one vendor's. On a drive drivedb does not recognise the
|
||||
# name is "Unknown_Attribute", nothing matches, and these stay null — no claim is made.
|
||||
smart_grown_blocks: >-
|
||||
{{ smart_attrs | selectattr('name', 'search', '(?i)(grown|runtime)[_ -]?(failing|bad)?[_ -]?block')
|
||||
| map(attribute='raw.value') | first | default(none) }}
|
||||
smart_erase_fail: "{{ smart_attrs | selectattr('name', 'search', '(?i)erase[_ -]?fail') | map(attribute='raw.value') | first | default(none) }}"
|
||||
smart_program_fail: "{{ smart_attrs | selectattr('name', 'search', '(?i)program[_ -]?fail|prog[_ -]?fail') | map(attribute='raw.value') | first | default(none) }}"
|
||||
# '(?i)power[_ -]?loss[_ -]?(ct|cnt|count)' deliberately does NOT match Intel's
|
||||
# Power_Loss_Cap_Test (a capacitor self-test result, not a count of power losses).
|
||||
smart_power_loss: >-
|
||||
{{ smart_attrs | selectattr('name', 'search', '(?i)(unexpect\w*|unsafe)[_ -]?(power|shutdown)|power[_ -]?loss[_ -]?(ct|cnt|count)')
|
||||
| map(attribute='raw.value') | first | default(none) }}
|
||||
|
||||
smart_disk:
|
||||
path: "{{ smart_result.item.path }}"
|
||||
# smartctl ran, but did it understand the device?
|
||||
available: "{{ (smart_json.smart_status is defined) or (smart_nvme | length > 0) }}"
|
||||
exit_status: "{{ smart_json.smartctl.exit_status | default(smart_result.rc) }}"
|
||||
messages: "{{ smart_json.smartctl.messages | default([]) | map(attribute='string') | list }}"
|
||||
protocol: "{{ smart_json.device.protocol | default(none) }}"
|
||||
model: "{{ smart_json.model_name | default(none) }}"
|
||||
serial: "{{ smart_json.serial_number | default(none) }}"
|
||||
firmware: "{{ smart_json.firmware_version | default(none) }}"
|
||||
# The headline: the drive's own overall self-assessment.
|
||||
passed: "{{ smart_json.smart_status.passed | default(none) }}"
|
||||
power_on_hours: "{{ smart_json.power_on_time.hours | default(none) }}"
|
||||
power_cycles: "{{ smart_json.power_cycle_count | default(none) }}"
|
||||
temperature_c: "{{ smart_json.temperature.current | default(none) }}"
|
||||
# Normalised-vs-threshold verdict inputs (see the long note above).
|
||||
failing_now: "{{ smart_failing_now }}"
|
||||
failing_now_prefail: "{{ smart_failing_now_prefail }}"
|
||||
failed_past: "{{ smart_failed_past }}"
|
||||
thresholds_present: "{{ (smart_attrs | selectattr('thresh', 'defined') | rejectattr('thresh', 'equalto', 0) | list | length) > 0 }}"
|
||||
prefail_closest: "{{ smart_prefail_margin_json | from_json }}"
|
||||
# ATA-standard error counters — same meaning on every drive that implements them.
|
||||
pending_sectors: "{{ smart_attrs | selectattr('id', 'equalto', 197) | map(attribute='raw.value') | first | default(none) }}"
|
||||
offline_uncorrectable: "{{ smart_attrs | selectattr('id', 'equalto', 198) | map(attribute='raw.value') | first | default(none) }}"
|
||||
reported_uncorrect: "{{ smart_attrs | selectattr('id', 'equalto', 187) | map(attribute='raw.value') | first | default(none) }}"
|
||||
crc_errors: "{{ smart_attrs | selectattr('id', 'equalto', 199) | map(attribute='raw.value') | first | default(none) }}"
|
||||
host_writes_raw: "{{ smart_attrs | selectattr('id', 'equalto', 241) | map(attribute='raw.value') | first | default(none) }}"
|
||||
# NVMe reports wear directly as "percentage_used"; ATA SSDs report a countdown.
|
||||
lifetime_remaining_pct: >-
|
||||
{{ (100 - smart_nvme.percentage_used) if (smart_nvme.percentage_used is defined)
|
||||
else (smart_life_202 if smart_life_202 is not none
|
||||
else (smart_life_231 if smart_life_231 is not none else smart_life_233)) }}
|
||||
nvme_media_errors: "{{ smart_nvme.media_errors | default(none) }}"
|
||||
nvme_critical_warning: "{{ smart_nvme.critical_warning | default(none) }}"
|
||||
nvme_unsafe_shutdowns: "{{ smart_nvme.unsafe_shutdowns | default(none) }}"
|
||||
# --- Vendor context. Never a verdict on its own; always printed with its own numbers.
|
||||
realloc_events: "{{ smart_realloc_events }}"
|
||||
grown_bad_blocks: "{{ smart_grown_blocks }}"
|
||||
erase_fail_count: "{{ smart_erase_fail }}"
|
||||
program_fail_count: "{{ smart_program_fail }}"
|
||||
unexpected_power_loss: "{{ smart_power_loss if smart_power_loss is not none else (smart_nvme.unsafe_shutdowns | default(none)) }}"
|
||||
# Attribute 5, whole and labelled, so the raw is visible BESIDE the normalised reading
|
||||
# rather than standing in for it. `reallocated_sectors` is deliberately gone: that name
|
||||
# asserted a unit the ATA spec does not guarantee, and asserting it is what produced the
|
||||
# false FAIL on k3s-prod-1 (see defaults/main.yml).
|
||||
realloc_attr: "{{ smart_attr5 }}"
|
||||
# Every attribute the operator asked to see, verbatim, names and all — vendors
|
||||
# disagree on naming, so the checks key off IDs and the report shows the drive's
|
||||
# own labels.
|
||||
attributes: "{{ smart_attrs | selectattr('id', 'in', node_health_smart_attribute_ids) | list }}"
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# Everything obtainable without root, in ONE round trip. See
|
||||
# files/node_health_collect.py for the full field-by-field rationale — including why the
|
||||
# collector is a script rather than 40 sysfs `cat` tasks, and why unreadable fields come
|
||||
# back as null + a recorded reason instead of an empty string.
|
||||
#
|
||||
# NOTE: the role runs with gather_facts irrelevant — the collector supersedes
|
||||
# ansible.builtin.setup for this purpose (setup exposes no thermal zones, no EDAC, no CPU
|
||||
# throttle counters, no PSI, no per-disk SCSI error counters, no per-NIC error stats), so
|
||||
# there is exactly one source of truth behind both the summary and the JSON artifact.
|
||||
|
||||
- name: Run the unprivileged node health collector
|
||||
ansible.builtin.script:
|
||||
cmd: node_health_collect.py
|
||||
executable: /usr/bin/python3
|
||||
register: node_health_collect_raw
|
||||
changed_when: false # strictly read-only; it can never change the node
|
||||
|
||||
- name: Parse the collector payload
|
||||
ansible.builtin.set_fact:
|
||||
node_health_facts: "{{ node_health_collect_raw.stdout | from_json }}"
|
||||
|
||||
- name: Assert the collector schema is the one this role understands
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- node_health_facts.schema == 'node-health/1'
|
||||
fail_msg: >-
|
||||
Collector returned schema {{ node_health_facts.schema | default('<none>') }};
|
||||
this role understands node-health/1. Role and collector are out of step.
|
||||
quiet: true
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
# node_health — read-only hardware health inspection. See defaults/main.yml for the why.
|
||||
#
|
||||
# ORDER OF OPERATIONS
|
||||
# 1. Collect everything readable WITHOUT root (one script, one round trip).
|
||||
# 2. Probe whether privilege escalation actually works on this host.
|
||||
# 3. Only if it does AND smartmontools is installed: read SMART.
|
||||
# 4. Turn facts into a list of explicit PASS/WARN/FAIL/SKIP checks.
|
||||
# 5. Render the human summary + write the JSON artifact.
|
||||
#
|
||||
# BECOME HANDLING — the subtle bit, read before editing.
|
||||
# `ansible_become` set as an inventory/group var (which is exactly what
|
||||
# inventory/group_vars/k3s_prod/vars.yml does for prod) is a CONNECTION VARIABLE, and
|
||||
# connection variables OUTRANK the `become:` play/task keyword. Verified on this repo's
|
||||
# ansible-core 2.20: a play or task saying `become: false` still tries to sudo, and on
|
||||
# prod (no NOPASSWD, vaulted password) dies with "Missing sudo password" before the
|
||||
# module ever runs. So the unprivileged tasks here must override the VARIABLE, not the
|
||||
# keyword — hence `vars: ansible_become: false` on the include below. That makes the role
|
||||
# self-contained: it behaves identically whatever the calling play sets.
|
||||
#
|
||||
# Equally verified: `failed_when: false` does NOT catch a missing-sudo-password error
|
||||
# (it is raised by the become plugin before the task's failure expression is evaluated).
|
||||
# `ignore_errors: true` DOES, and still populates the registered variable. That is why
|
||||
# the escalation probe below uses ignore_errors — anything else turns "no vault yet" into
|
||||
# a hard, misleading play failure.
|
||||
|
||||
- name: Initialise the health record
|
||||
ansible.builtin.set_fact:
|
||||
node_health_checks: []
|
||||
node_health_smart: []
|
||||
node_health_root_available: false
|
||||
node_health_root_skip_reason: "privileged checks not attempted (node_health_try_privileged=false)"
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Collect unprivileged hardware facts
|
||||
ansible.builtin.include_tasks: collect_unprivileged.yml
|
||||
# Applies to every task in the included file (dynamic includes pass their vars down).
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Probe privilege escalation
|
||||
ansible.builtin.include_tasks: probe_privilege.yml
|
||||
when: node_health_try_privileged | bool
|
||||
|
||||
- name: Read SMART data (privileged)
|
||||
ansible.builtin.include_tasks: collect_smart.yml
|
||||
when:
|
||||
- node_health_root_available | bool
|
||||
- node_health_facts.tools.smartctl is truthy
|
||||
|
||||
- name: Assess collected facts into explicit checks
|
||||
ansible.builtin.include_tasks: assess.yml
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Report (human summary + JSON artifact)
|
||||
ansible.builtin.include_tasks: report.yml
|
||||
vars:
|
||||
ansible_become: false
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Does `become` actually work on this host, right now?
|
||||
#
|
||||
# We must know BEFORE attempting SMART, because a failed escalation is a hard task error
|
||||
# (see the become note in main.yml): `failed_when: false` cannot swallow it, only
|
||||
# `ignore_errors` can. Probing once with a trivial, side-effect-free command means the
|
||||
# rest of the role can branch on a boolean instead of littering ignore_errors everywhere —
|
||||
# and lets the report state the exact reason a check was skipped.
|
||||
#
|
||||
# On prod today this probe is EXPECTED to fail: prod deliberately has no NOPASSWD sudo and
|
||||
# its ansible_become_password lives in an ansible-vault file that does not exist yet
|
||||
# (infra/PROD-PROVISIONING.md §5, DECISIONS-LOG 2026-08-31 finding 3). That is a SKIP, not
|
||||
# a defect, and the summary says so in those words.
|
||||
|
||||
- name: Probe whether privilege escalation is available
|
||||
ansible.builtin.command:
|
||||
cmd: id -u
|
||||
become: true
|
||||
vars:
|
||||
ansible_become: true
|
||||
register: node_health_root_probe
|
||||
changed_when: false
|
||||
ignore_errors: true
|
||||
|
||||
- name: Record the escalation result
|
||||
ansible.builtin.set_fact:
|
||||
node_health_root_available: "{{ (node_health_root_probe.stdout | default('') | trim) == '0' }}"
|
||||
node_health_root_skip_reason: >-
|
||||
{{ 'privilege escalation OK'
|
||||
if (node_health_root_probe.stdout | default('') | trim) == '0'
|
||||
else 'become unavailable: ' ~ (node_health_root_probe.msg | default('unknown error'))
|
||||
~ ' (prod uses a vaulted ansible_become_password —'
|
||||
~ ' see infra/PROD-PROVISIONING.md §5)' }}
|
||||
vars:
|
||||
ansible_become: false
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# Two outputs, deliberately:
|
||||
#
|
||||
# 1. A human summary the operator can read at a glance (printed in the play output AND
|
||||
# written next to the JSON, so it survives the terminal scrollback).
|
||||
# 2. A structured JSON artifact — the machine-readable record, and the thing a future
|
||||
# R-OBS-04 exporter or a prod-readiness gate can consume without re-parsing prose.
|
||||
#
|
||||
# Artifacts follow the repo's existing convention (conformance/_artifacts/): generated
|
||||
# output under a `_artifacts/` directory that .gitignore excludes. Never committed — they
|
||||
# are regenerated on every run and they carry drive serial numbers.
|
||||
|
||||
- name: Count check outcomes
|
||||
ansible.builtin.set_fact:
|
||||
node_health_counts:
|
||||
FAIL: "{{ node_health_checks | selectattr('status', 'equalto', 'FAIL') | list | length }}"
|
||||
WARN: "{{ node_health_checks | selectattr('status', 'equalto', 'WARN') | list | length }}"
|
||||
PASS: "{{ node_health_checks | selectattr('status', 'equalto', 'PASS') | list | length }}"
|
||||
SKIP: "{{ node_health_checks | selectattr('status', 'equalto', 'SKIP') | list | length }}"
|
||||
INFO: "{{ node_health_checks | selectattr('status', 'equalto', 'INFO') | list | length }}"
|
||||
|
||||
# Rendered once, then used for BOTH the on-screen report and the .txt artifact, so the
|
||||
# two can never drift.
|
||||
- name: Render the human summary
|
||||
ansible.builtin.set_fact:
|
||||
node_health_summary: "{{ lookup('ansible.builtin.template', 'summary.txt.j2') }}"
|
||||
|
||||
- name: Show the summary
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ node_health_summary.split('\n') }}"
|
||||
when: node_health_show_summary | bool
|
||||
|
||||
- name: Ensure the artifact directory exists (on the controller)
|
||||
ansible.builtin.file:
|
||||
path: "{{ node_health_artifact_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: node_health_write_artifacts | bool
|
||||
|
||||
- name: Write the structured health record (JSON)
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ node_health_artifact_dir }}/{{ inventory_hostname }}.json"
|
||||
mode: "0644"
|
||||
content: "{{ node_health_record | to_nice_json }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: node_health_write_artifacts | bool
|
||||
vars:
|
||||
node_health_record:
|
||||
schema: node-health/1
|
||||
host: "{{ inventory_hostname }}"
|
||||
address: "{{ ansible_host | default(inventory_hostname) }}"
|
||||
collected_at: "{{ node_health_facts.collected_at }}"
|
||||
privileged:
|
||||
available: "{{ node_health_root_available }}"
|
||||
reason: "{{ node_health_root_skip_reason }}"
|
||||
counts: "{{ node_health_counts }}"
|
||||
checks: "{{ node_health_checks }}"
|
||||
smart: "{{ node_health_smart }}"
|
||||
facts: "{{ node_health_facts }}"
|
||||
|
||||
- name: Write the human summary alongside it
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ node_health_artifact_dir }}/{{ inventory_hostname }}.txt"
|
||||
mode: "0644"
|
||||
content: "{{ node_health_summary }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: node_health_write_artifacts | bool
|
||||
|
||||
# Report-only by default (see defaults/main.yml): a health check that aborts the play
|
||||
# hides the other twenty findings. Opt in when using this role as a pre-build gate.
|
||||
- name: Fail the play on health findings (opt-in)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (node_health_counts.FAIL | int) == 0
|
||||
- not (node_health_fail_on_warn | bool) or (node_health_counts.WARN | int) == 0
|
||||
fail_msg: >-
|
||||
node_health: {{ node_health_counts.FAIL }} FAIL / {{ node_health_counts.WARN }} WARN
|
||||
on {{ inventory_hostname }} — see {{ node_health_artifact_dir }}/{{ inventory_hostname }}.txt
|
||||
quiet: true
|
||||
when: node_health_fail_on_fail | bool
|
||||
@@ -0,0 +1,167 @@
|
||||
{# ===========================================================================
|
||||
node_health human summary. Rendered ONCE (tasks/report.yml) and used for both
|
||||
the on-screen report and the .txt artifact, so the two can never drift.
|
||||
|
||||
Detail strings are normalised with regex_replace('\s+',' ') because the check
|
||||
details are authored as YAML folded scalars in assess.yml and can carry stray
|
||||
newlines/indentation from that folding.
|
||||
=========================================================================== #}
|
||||
{% macro fmt(text, width=74, pad=10) -%}
|
||||
{{ text | regex_replace('\s+', ' ') | trim | wordwrap(width) | indent(pad, first=True) }}
|
||||
{%- endmacro %}
|
||||
{% macro gib(n) -%}
|
||||
{{ (n | int / 1073741824) | round(1) }} GiB
|
||||
{%- endmacro %}
|
||||
================================================================================
|
||||
NODE HEALTH — {{ inventory_hostname }} ({{ ansible_host | default('n/a') }})
|
||||
collected {{ node_health_facts.collected_at }} as uid {{ node_health_facts.collector_uid }}
|
||||
================================================================================
|
||||
|
||||
VERDICT {{ node_health_counts.FAIL }} FAIL · {{ node_health_counts.WARN }} WARN · {{ node_health_counts.PASS }} PASS · {{ node_health_counts.SKIP }} SKIP · {{ node_health_counts.INFO }} INFO
|
||||
{{ 'privileged checks RAN' if node_health_root_available | bool else 'privileged checks SKIPPED — root not available' }}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
CHECKS
|
||||
--------------------------------------------------------------------------------
|
||||
{% for c in node_health_checks %}
|
||||
[{{ '%-4s' | format(c.status) }}] {{ c.id }} — {{ c.title }}
|
||||
{{ fmt(c.detail) }}
|
||||
{% endfor %}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
MACHINE — {{ node_health_facts.dmi.sys_vendor | default('?') }} {{ node_health_facts.dmi.product_name | default('?') }}
|
||||
--------------------------------------------------------------------------------
|
||||
OS {{ node_health_facts.os.pretty_name | default('?') }}
|
||||
Kernel {{ node_health_facts.os.kernel | default('?') }}
|
||||
Uptime {{ (node_health_facts.os.uptime_seconds | float / 86400) | round(2) }} days
|
||||
BIOS {{ node_health_facts.dmi.bios_vendor | default('?') }} {{ node_health_facts.dmi.bios_version | default('?') }} ({{ node_health_facts.dmi.bios_date | default('?') }}){% if node_health_facts.dmi.ec_firmware_release %}, EC {{ node_health_facts.dmi.ec_firmware_release }}{% endif %}
|
||||
|
||||
Board {{ node_health_facts.dmi.board_vendor | default('?') }} {{ node_health_facts.dmi.board_name | default('?') }}
|
||||
CPU {{ node_health_facts.cpu.model | default('?') }}
|
||||
{{ node_health_facts.cpu.cpus | default('?') }} logical / {{ node_health_facts.cpu.sockets | default('?') }} socket(s) x {{ node_health_facts.cpu.cores_per_socket | default('?') }} core(s) x {{ node_health_facts.cpu.threads_per_core | default('?') }} thread(s)
|
||||
virtualisation: {{ node_health_facts.cpu.virtualization | default('none reported') }}, /dev/kvm={{ node_health_facts.cpu.kvm_device }}
|
||||
{% if node_health_facts.cpu.scaling %}
|
||||
scaling: {{ node_health_facts.cpu.scaling.driver }}/{{ node_health_facts.cpu.scaling.governor }}, {{ (node_health_facts.cpu.scaling.min_khz | int / 1000) | round | int }}–{{ (node_health_facts.cpu.scaling.max_khz | int / 1000) | round | int }} MHz (now {{ (node_health_facts.cpu.scaling.cur_khz | int / 1000) | round | int }} MHz)
|
||||
{% endif %}
|
||||
Memory {{ (node_health_facts.memory.mem_total_kb | int / 1048576) | round(1) }} GiB total, {{ (node_health_facts.memory.mem_available_kb | int / 1048576) | round(1) }} GiB available, swap {{ (node_health_facts.memory.swap_total_kb | int / 1048576) | round(2) }} GiB
|
||||
ECC/EDAC {{ 'controllers: ' ~ (node_health_facts.memory.edac.controllers | map(attribute='name') | join(', ')) ~ ' CE=' ~ node_health_facts.memory.edac.ce_total | string ~ ' UE=' ~ node_health_facts.memory.edac.ue_total | string if node_health_facts.memory.edac.controllers_present else 'not exposed (no EDAC controller bound — ECC status UNKNOWN on this platform)' }}
|
||||
Load {{ node_health_facts.cpu.loadavg | default([]) | join(' ') }}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
STORAGE
|
||||
--------------------------------------------------------------------------------
|
||||
{% for d in node_health_facts.storage.disks %}
|
||||
{{ d.path }} {{ gib(d.size_bytes) }} {{ 'rotational' if d.rotational else 'non-rotating' }} {{ d.transport | default('?', true) }}{% if d.fstype %} whole-device {{ d.fstype }}{% endif %}{% if d.mountpoints %} mounted at {{ d.mountpoints | join(',') }}{% endif %}
|
||||
|
||||
model {{ d.model | default('(none reported)', true) }}{% if d.firmware_rev %} fw {{ d.firmware_rev }}{% endif %}{% if d.serial %} s/n {{ d.serial }}{% endif %}
|
||||
|
||||
{% if d.scsi_counters %}
|
||||
kernel I/O completed={{ d.scsi_counters.iodone_cnt }} errors={{ d.scsi_counters.ioerr_cnt }} timeouts={{ d.scsi_counters.iotmo_cnt }}
|
||||
{% endif %}
|
||||
{% set sm = (node_health_smart | selectattr('path', 'equalto', d.path) | list | first) | default(none) %}
|
||||
{% if sm and sm.available %}
|
||||
SMART overall-health={{ 'PASSED' if sm.passed else 'FAILED' }} power_on_hours={{ sm.power_on_hours | default('?') }} power_cycles={{ sm.power_cycles | default('?') }}
|
||||
lifetime_remaining={{ (sm.lifetime_remaining_pct | string ~ '%') if sm.lifetime_remaining_pct is not none else 'not reported' }} temp={{ (sm.temperature_c | string ~ 'C') if sm.temperature_c is not none else 'not reported by this drive' }}
|
||||
pending(197)={{ sm.pending_sectors | default('?') }} offline_uncorrectable(198)={{ sm.offline_uncorrectable | default('?') }} reported_uncorrect(187)={{ sm.reported_uncorrect | default('?') }} crc(199)={{ sm.crc_errors | default('?') }}
|
||||
{% if sm.failing_now or sm.failed_past %}
|
||||
THRESHOLD TRIPS{% for a in sm.failing_now %} [{{ a.id }} {{ a.name }} FAILING_NOW value={{ a.value }}<=thresh={{ a.thresh }}{{ ' Pre-fail' if a.flags.prefailure | default(false) else ' Old_age' }}]{% endfor %}{% for a in sm.failed_past %} [{{ a.id }} {{ a.name }} In_the_past worst={{ a.worst }}<=thresh={{ a.thresh }}]{% endfor %}
|
||||
|
||||
{% elif sm.thresholds_present %}
|
||||
no attribute at/below its manufacturer threshold{% if sm.prefail_closest %}; closest Pre-fail margin: {{ sm.prefail_closest.id }} {{ sm.prefail_closest.name }} value={{ sm.prefail_closest.value }} vs thresh={{ sm.prefail_closest.thresh }}{% endif %}
|
||||
|
||||
{% endif %}
|
||||
vendor context (NOT used for the verdict — raw units are vendor-defined):
|
||||
reallocation events(196)={{ sm.realloc_events if sm.realloc_events is not none else 'n/a' }} grown bad blocks={{ sm.grown_bad_blocks if sm.grown_bad_blocks is not none else 'n/a' }} erase fails={{ sm.erase_fail_count if sm.erase_fail_count is not none else 'n/a' }} program fails={{ sm.program_fail_count if sm.program_fail_count is not none else 'n/a' }} unexpected power loss={{ sm.unexpected_power_loss if sm.unexpected_power_loss is not none else 'n/a' }}
|
||||
{% if sm.realloc_attr %}
|
||||
attr 5 {{ sm.realloc_attr.name }} raw={{ sm.realloc_attr.raw.string | default(sm.realloc_attr.raw.value) }} (vendor-encoded) vs normalised value={{ sm.realloc_attr.value }} thresh={{ sm.realloc_attr.thresh }} (the signal that is judged)
|
||||
{% endif %}
|
||||
{% for a in sm.attributes | default([]) %}
|
||||
[{{ '%3d' | format(a.id) }}] {{ '%-26s' | format(a.name) }} value={{ a.value }} worst={{ a.worst }} thresh={{ a.thresh }} raw={{ a.raw.string | default(a.raw.value) }}{% if a.when_failed | default('') %} <<< when_failed={{ a.when_failed }}{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
SMART NOT READ — {{ (node_health_root_skip_reason if not node_health_root_available else ('smartmontools not installed on this node (ships with roles/node_baseline; this role never installs packages)' if not node_health_facts.tools.smartctl else 'device exposes no SMART data')) | regex_replace('\s+', ' ') | trim }}
|
||||
{% endif %}
|
||||
{% for p in d.partitions %}
|
||||
{{ '%-12s' | format(p.kname) }} {{ '%9s' | format(gib(p.size_bytes)) }} {{ '%-6s' | format(p.fstype | default('-', true)) }} {{ (p.mountpoints[:2] | join(', ') ~ (' (+%d more)' | format(p.mountpoints | length - 2) if (p.mountpoints | length) > 2 else '')) if p.mountpoints else '(unmounted)' }}
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
{% if node_health_facts.storage.attached_volumes | default([]) %}
|
||||
ATTACHED VOLUMES (iSCSI/FC — Longhorn replicas etc., not hardware in this box)
|
||||
{% for a in node_health_facts.storage.attached_volumes %}
|
||||
{{ '%-10s' | format(a.path) }} {{ '%9s' | format(gib(a.size_bytes)) }} {{ a.transport }} {{ a.model | default('-', true) }}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
MOUNTED FILESYSTEMS
|
||||
{% for m in node_health_facts.storage.mounts | sort(attribute='mountpoint') %}
|
||||
{{ '%-14s' | format(m.mountpoint) }} {{ '%-6s' | format(m.fstype) }} {{ '%9s' | format(gib(m.size_bytes)) }} total, {{ '%9s' | format(gib(m.avail_bytes)) }} free ({{ m.used_pct }}% used, inodes {{ (m.inodes_used_pct | string ~ '%') if m.inodes_used_pct is not none else 'n/a' }})
|
||||
{% endfor %}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
THERMAL
|
||||
--------------------------------------------------------------------------------
|
||||
{% for z in node_health_facts.thermal.zones %}
|
||||
zone {{ '%-18s' | format(z.type | default(z.zone)) }} {{ (z.temp_c | string ~ 'C') if z.temp_c is not none else 'unreadable (sensor returned no data)' }}{% if z.critical_c %} (critical {{ z.critical_c }}C){% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% for chip in node_health_facts.thermal.hwmon %}
|
||||
{% for t in chip.temps %}
|
||||
{{ '%-16s' | format(chip.name | default('hwmon')) }} {{ '%-22s' | format(t.label) }} {{ (t.temp_c | string ~ 'C') if t.temp_c is not none else 'unreadable (sensor returned no data)' }}{% if t.crit_c %} (crit {{ t.crit_c }}C){% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% for f in chip.fans %}
|
||||
{{ '%-16s' | format(chip.name | default('hwmon')) }} {{ '%-22s' | format(f.label) }} {{ f.rpm }} rpm
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
PRESSURE STALL (/proc/pressure — % of wall time stalled)
|
||||
--------------------------------------------------------------------------------
|
||||
{% for res in ['cpu', 'memory', 'io'] %}
|
||||
{% set p = node_health_facts.pressure[res] %}
|
||||
{% if p %}
|
||||
{{ '%-7s' | format(res) }} some avg10={{ p.some.avg10 }} avg60={{ p.some.avg60 }} avg300={{ p.some.avg300 }}{% if p.full is defined %} | full avg60={{ p.full.avg60 }}{% endif %}
|
||||
|
||||
{% else %}
|
||||
{{ '%-7s' | format(res) }} unavailable
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
NETWORK
|
||||
--------------------------------------------------------------------------------
|
||||
{% for n in node_health_facts.network.interfaces %}
|
||||
{{ '%-10s' | format(n.name) }} {{ '%-6s' | format(n.operstate) }} {{ '%-8s' | format((n.speed_mbps | string ~ 'Mb/s') if n.speed_mbps else 'no-link') }} {{ '%-9s' | format(n.duplex | default('-')) }} mtu {{ n.mtu }} driver {{ n.driver | default('-') }}
|
||||
rx {{ n.statistics.rx_packets | default('?') }} pkts / errors {{ n.statistics.rx_errors | default('?') }} / dropped {{ n.statistics.rx_dropped | default('?') }}
|
||||
tx {{ n.statistics.tx_packets | default('?') }} pkts / errors {{ n.statistics.tx_errors | default('?') }} / dropped {{ n.statistics.tx_dropped | default('?') }}
|
||||
{% endfor %}
|
||||
{% if node_health_facts.network.nameservers is none %}
|
||||
nameservers NOT READ — {{ node_health_facts.network.resolver_unavailable_reason | default('the collector recorded no reason', true) | regex_replace('\s+', ' ') | trim | wordwrap(66) | indent(13) }}
|
||||
{% else %}
|
||||
nameservers {{ node_health_facts.network.nameservers | join(', ') | default('none configured', true) }}
|
||||
{% if node_health_facts.network.resolver_stub | default([], true) | length > 0 %}
|
||||
(/etc/resolv.conf holds only the systemd-resolved stub {{ node_health_facts.network.resolver_stub | join(', ') }}; the servers above are the real upstreams behind it)
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
NOT READ (and why) — these are UNKNOWNS, not passes
|
||||
--------------------------------------------------------------------------------
|
||||
{% for c in node_health_checks if c.status == 'SKIP' %}
|
||||
- {{ c.id }}: {{ c.detail | regex_replace('\s+', ' ') | trim | wordwrap(70) | indent(3) }}
|
||||
{% else %}
|
||||
(nothing skipped)
|
||||
{% endfor %}
|
||||
{% if node_health_facts.unreadable %}
|
||||
|
||||
Unreadable paths reported by the collector:
|
||||
{% for u in node_health_facts.unreadable %}
|
||||
- {{ u.path }} — {{ u.reason }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
tools present: {% for t, p in node_health_facts.tools.items() %}{{ t }}={{ p | default('MISSING', true) }}{{ ', ' if not loop.last }}{% endfor %}
|
||||
|
||||
================================================================================
|
||||
+10
-1
@@ -159,8 +159,17 @@ resource "proxmox_virtual_environment_vm" "node" {
|
||||
}
|
||||
}
|
||||
|
||||
# DNS resilience (dev only — tofu provisions the dev VMs; prod is bare metal with no
|
||||
# cloud-init). A SINGLE upstream was a hard SPOF: PiHole (var.dns_server) answers the
|
||||
# local jackson.lan zone but its external recursion flaps, which broke containerd image
|
||||
# pulls and CoreDNS forwarding. PiHole stays FIRST — the local zone and network
|
||||
# ad-blocking are unchanged — and the pfSense gateway (var.network_gateway) is added
|
||||
# purely as a fallback for external recursion. Order is the policy, not a preference.
|
||||
# This keeps a freshly built node's cloud-init in parity with what the Ansible
|
||||
# node_baseline role's `dns`-tagged block configures on an already-running node
|
||||
# (node_baseline_dns_servers), so a rebuild does not regress to one resolver.
|
||||
dns {
|
||||
servers = [var.dns_server]
|
||||
servers = [var.dns_server, var.network_gateway]
|
||||
}
|
||||
|
||||
user_account {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
# CiliumL2AnnouncementPolicy — ARP-announce LoadBalancer VIPs on the node LAN so hosts on
|
||||
# 192.168.1.0/24 can reach Gateway listeners at the pool IP (l2announcements.enabled=true in the
|
||||
# CiliumL2AnnouncementPolicy — ARP-announce LoadBalancer VIPs on the node LAN so LAN hosts (dev
|
||||
# 192.168.1.0/24, prod 192.168.2.0/24) can reach Gateway listeners at the pool IP. The RANGE itself
|
||||
# is env-scoped in l2-ip-pool.yaml via CILIUM_LB_POOL_START/STOP; this policy is env-agnostic and
|
||||
# needs no cluster-vars knob (l2announcements.enabled=true in the
|
||||
# Cilium HelmRelease turns on the controller; this policy scopes WHAT/WHERE it announces).
|
||||
# Announces on the primary ethernet interface. On Proxmox VMs the NIC is typically ens18/eth0;
|
||||
# `^e(n|th)` matches ens*/enp*/eth* so the exact name need not be pinned. VERIFY-AT-BRINGUP: if
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
---
|
||||
# CiliumLoadBalancerIPPool — reserved VIP range for LoadBalancer Services. Gateway API listeners
|
||||
# (gatewayClassName: cilium) get their address from here. Range matches the tofu reservation
|
||||
# (cilium_l2_vip_range 192.168.1.53-.60) on the Proxmox LAN 192.168.1.0/24. These addresses sit
|
||||
# OUTSIDE the node IPs (.50/.51/.52) and any DHCP scope, so nothing else claims them.
|
||||
# (gatewayClassName: cilium) get their address from here. The range must match the environment's tofu
|
||||
# reservation (cilium_l2_vip_range) and sit OUTSIDE the node IPs and any DHCP scope, so nothing else
|
||||
# claims them.
|
||||
#
|
||||
# NOTE (dev): the range is hardcoded to the dev LAN reservation. A prod overlay must supply the
|
||||
# prod LAN range (parameterize via cluster-vars when prod hardware is built). See DECISIONS-LOG.
|
||||
# R-ENV-02: the range was HARDCODED to the dev LAN (192.168.1.53-.60 on the Proxmox LAN 192.168.1.0/24,
|
||||
# nodes .50/.51/.52) until 2026-08-31; it is now ${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP} from
|
||||
# cluster-vars, substituted by the `platform-config` Flux Kustomization.
|
||||
#
|
||||
# CONTAINMENT INVARIANT — ${GATEWAY_VIP} MUST be inside this block, and in practice EQUAL to
|
||||
# ${CILIUM_LB_POOL_START}. The `shared` Gateway (platform/gateway/gateway.yaml) pins NOTHING: it has
|
||||
# no `lbipam.cilium.io/ips` annotation and no spec.addresses (unlike platform/crafty/service-l4.yaml
|
||||
# and apps/vms/unifi/50-service-l4.yaml, which DO pin). Cilium's Gateway controller creates a
|
||||
# `cilium-gateway-shared` LoadBalancer Service and LB-IPAM assigns it the FIRST FREE address of a
|
||||
# matching pool block — so the Gateway lands on POOL_START, and dev's GATEWAY_VIP (.53) IS dev's
|
||||
# POOL_START (.53-.60). A GATEWAY_VIP outside every pool is NOT allocatable: the Gateway would come
|
||||
# up on POOL_START anyway while split-horizon coredns-custom keeps answering *.home.keithjackson.dev
|
||||
# with the unannounced GATEWAY_VIP, blackholing in-cluster hairpin traffic while looking healthy.
|
||||
#
|
||||
# RESOLVED (operator, 2026-08-31): the invariant holds in BOTH environments. An earlier prod
|
||||
# proposal put GATEWAY_VIP .20 outside a .21-.30 pool; the operator widened the prod pool to
|
||||
# 192.168.2.20-.30 so GATEWAY_VIP (.20) IS prod's POOL_START, exactly as dev's .53 is dev's.
|
||||
# Prod's kube-vip CONTROL-PLANE VIP (192.168.2.9) sits deliberately BELOW the pool so kube-vip
|
||||
# and LB-IPAM can never contend for an address.
|
||||
apiVersion: cilium.io/v2alpha1
|
||||
kind: CiliumLoadBalancerIPPool
|
||||
metadata:
|
||||
name: default-pool
|
||||
spec:
|
||||
blocks:
|
||||
- start: "192.168.1.53"
|
||||
stop: "192.168.1.60"
|
||||
- start: "${CILIUM_LB_POOL_START}"
|
||||
stop: "${CILIUM_LB_POOL_STOP}"
|
||||
@@ -45,9 +45,12 @@ spec:
|
||||
# [parity] MUST equal Ansible: kubeProxyReplacement: true
|
||||
kubeProxyReplacement: true
|
||||
# [parity] MUST equal Ansible: k8sServiceHost/k8sServicePort = the k3s API endpoint
|
||||
# (k3s_api_host/k3s_api_port). This is the primary control-plane node IP (192.168.1.50).
|
||||
# (k3s_api_host/k3s_api_port). This is the primary control-plane node IP.
|
||||
# Setting these wrong breaks Cilium's connection to the API server on adopt.
|
||||
k8sServiceHost: "192.168.1.50"
|
||||
# ${K3S_API_HOST} (cluster-vars, R-ENV-02): dev 192.168.1.50 | prod set in clusters/prod.
|
||||
# The `platform` Flux Kustomization substitutes it; the Ansible side is already parameterized
|
||||
# from inventory (k3s_api_host), so the parity contract holds per-environment, not per-literal.
|
||||
k8sServiceHost: "${K3S_API_HOST}"
|
||||
k8sServicePort: 6443
|
||||
# --- Gateway API (ingress; replaces Traefik) — docs/09, ADR-0002 ---
|
||||
# [parity] MUST equal Ansible: gatewayAPI.enabled: true
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
# [parity] k3s kube-proxy disabled → Cilium replaces it (ADR-0001/0002)
|
||||
kubeProxyReplacement: true
|
||||
# [parity] = the k3s API endpoint (primary control-plane node IP) + port
|
||||
k8sServiceHost: "192.168.1.50"
|
||||
# [parity] = the k3s API endpoint (primary control-plane node IP) + port.
|
||||
# Mirrors the HelmRelease's ${K3S_API_HOST} cluster-vars token (R-ENV-02). This file is NOT a
|
||||
# kustomize resource, so nothing substitutes it — the token is shown with its dev value beside it.
|
||||
k8sServiceHost: "${K3S_API_HOST}" # dev: 192.168.1.50
|
||||
k8sServicePort: 6443
|
||||
# [parity] ingress via Gateway API, no Traefik (docs/09)
|
||||
gatewayAPI:
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
# the crafty pod's network namespace. The Gateway API `shared` Gateway is an L7 HTTPS terminator and
|
||||
# CANNOT express raw TCP on arbitrary ports or UDP at all. So the game ports are exposed as
|
||||
# type=LoadBalancer Services whose VIP is pulled from the CiliumLoadBalancerIPPool `default-pool`
|
||||
# (192.168.1.53-.60) and ARP-announced on the flat LAN by the existing CiliumL2AnnouncementPolicy
|
||||
# (dev 192.168.1.53-.60; see CILIUM_LB_POOL_* in cluster-vars) and ARP-announced on the flat LAN by
|
||||
# the existing CiliumL2AnnouncementPolicy
|
||||
# `default-l2` (platform-config/cilium/*) — the SAME machinery the Gateway VIP and unifi already use.
|
||||
# No new Cilium config is needed; these Services just consume the pool.
|
||||
#
|
||||
@@ -15,8 +16,9 @@
|
||||
# control plane) is Keycloak-gated (via the oauth2-proxy sidecar); the game ports carry player traffic
|
||||
# and must reach the server subprocesses directly.
|
||||
#
|
||||
# VIP: 192.168.1.55 — the next free address in default-pool (.53 = shared Gateway, .54 = unifi;
|
||||
# verified free live 2026-08-28). PINNED via lbipam.cilium.io/ips so players have a stable target.
|
||||
# VIP: CRAFTY_L4_VIP (cluster-vars, R-ENV-02; dev 192.168.1.55) — the next free address in
|
||||
# default-pool (dev: .53 = shared Gateway, .54 = unifi; verified free live 2026-08-28). PINNED via
|
||||
# lbipam.cilium.io/ips so players have a stable target. The `crafty` Flux Kustomization substitutes it.
|
||||
# ONE VIP, TWO SERVICES: a LoadBalancer Service is single-protocol-friendly, so TCP and UDP split into
|
||||
# two Services sharing the IP via lbipam.cilium.io/sharing-key (same pattern as unifi).
|
||||
#
|
||||
@@ -38,8 +40,8 @@ metadata:
|
||||
app.kubernetes.io/name: crafty
|
||||
annotations:
|
||||
lbipam.cilium.io/sharing-key: "crafty"
|
||||
lbipam.cilium.io/ips: "192.168.1.55"
|
||||
# external-dns publishes A crafty-mc.home.keithjackson.dev -> 192.168.1.55 (the Java server target).
|
||||
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
|
||||
# external-dns publishes A crafty-mc.home.keithjackson.dev -> the pinned VIP (Java server target).
|
||||
external-dns.alpha.kubernetes.io/hostname: "crafty-mc.home.keithjackson.dev"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
@@ -60,7 +62,7 @@ metadata:
|
||||
app.kubernetes.io/name: crafty
|
||||
annotations:
|
||||
lbipam.cilium.io/sharing-key: "crafty" # same key + same IP => shares the VIP with -tcp
|
||||
lbipam.cilium.io/ips: "192.168.1.55"
|
||||
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
@@ -40,11 +40,13 @@ spec:
|
||||
# to the Cluster name), so objects land under s3://cnpg/keycloak-db/. Path-style HTTP MinIO endpoint.
|
||||
# Credentials: the scoped key from the in-namespace SOPS Secret keycloak-db-backup-credentials
|
||||
# (a Pod only reads same-ns Secrets), kept in sync with platform/backup/minio-credentials.sops.yaml.
|
||||
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000) — prod
|
||||
# MUST point at its own off-cluster target. Substituted by the `platform-config` Kustomization.
|
||||
backup:
|
||||
retentionPolicy: "30d" # keep 30 days of base backups + WAL (PITR window); prunes older
|
||||
barmanObjectStore:
|
||||
destinationPath: s3://cnpg
|
||||
endpointURL: http://192.168.1.9:9000
|
||||
endpointURL: ${S3_BACKUP_ENDPOINT}
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: keycloak-db-backup-credentials
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
# Velero — off-cluster backup/DR of cluster resources + PVC data (ADR-0003, R-STATE-03). LIVE.
|
||||
#
|
||||
# TARGET: the on-Proxmox MinIO S3 endpoint http://192.168.1.9:9000 (plain HTTP, path-style), bucket
|
||||
# TARGET: the MinIO S3 endpoint ${S3_BACKUP_ENDPOINT} (cluster-vars; dev http://192.168.1.9:9000 on
|
||||
# Proxmox — plain HTTP, path-style), bucket
|
||||
# `velero`. region us-east-1 is a placeholder MinIO ignores (S3 clients still require one).
|
||||
#
|
||||
# WHY THIS SHAPE:
|
||||
@@ -75,7 +76,7 @@ spec:
|
||||
default: true
|
||||
config:
|
||||
region: us-east-1 # placeholder; MinIO ignores it (S3 clients require one)
|
||||
s3Url: http://192.168.1.9:9000 # on-Proxmox MinIO, plain HTTP
|
||||
s3Url: ${S3_BACKUP_ENDPOINT} # on-Proxmox MinIO, plain HTTP (cluster-vars)
|
||||
s3ForcePathStyle: "true" # MinIO is path-style
|
||||
credentials:
|
||||
existingSecret: velero-cloud-credentials # SOPS Secret carrying the `cloud` INI key (ns velero)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# doc pointer, kept for parity with the other components' layout).
|
||||
#
|
||||
# LIVE wiring (see helmrelease.yaml):
|
||||
# - provider aws + velero-plugin-for-aws (init container) → MinIO S3 http://192.168.1.9:9000
|
||||
# - provider aws + velero-plugin-for-aws (init container) → MinIO S3 ${S3_BACKUP_ENDPOINT} (dev: http://192.168.1.9:9000)
|
||||
# (path-style, bucket `velero`, region us-east-1 placeholder).
|
||||
# - deployNodeAgent:true + uploaderType:kopia → File System Backup of PVC data (no CSI data-mover).
|
||||
# - snapshotsEnabled:false → no VolumeSnapshotLocation.
|
||||
|
||||
Reference in new issue
Block a user