Operator ask: implement the roles needed to check prod node hardware health, and run them to find out our state. This is that role; the run's findings are below. roles/node_health is a read-only sibling to cluster_verify (cluster health vs hardware health), styled after node_baseline. One unprivileged collector script does the work in a single SSH round trip — reading /sys/class/dmi, thermal + hwmon, EDAC, cpufreq + thermal_throttle, /proc/pressure, /sys/block incl. SCSI ioerr/iotmo counters, lsblk, lscpu, mounts + statvfs, per-NIC stats, resolv.conf. THE DESIGN POINT: an unreadable field is emitted as null WITH A RECORDED REASON, never as zero. The summary ends with a "NOT READ (and why) — these are UNKNOWNS, not passes" section. A fake zero on a reallocated-sector count is exactly the kind of false green this project refuses. 22 checks report PASS/WARN/FAIL/SKIP/INFO; every SKIP says why. Report-only by default; thresholds live in defaults/main.yml. Portable across bare metal AND VMs — validated against k3s-prod-1 (bare metal, no root) and k3s-dev-1 (VM, root). VM-absent things (SMART on virtio, coretemp, EDAC) skip cleanly, and iSCSI/Longhorn volumes are split out of the physical-disk set so a live replica is never mistaken for a spare device. playbooks/node-health.yml takes its target group as a variable (-e node_health_hosts= k3s_prod), deliberately unlike the other playbooks that hardcode k3s_servers — see the group-name gap already flagged in hosts.ini. Two become facts found the hard way and documented in the role: a group_vars `ansible_become: true` OUTRANKS `become: false` as a play/task keyword, so unprivileged tasks must override the variable; and `failed_when: false` does NOT catch "Missing sudo password" — only `ignore_errors` does. FIRST RUN, k3s-prod-1, 2026-08-31: 0 FAIL · 3 WARN · 12 PASS · 3 SKIP · 4 INFO. Box is healthy, cool and unstressed: i7-9700T 8C/8T, VT-x + /dev/kvm (KubeVirt OK), zero throttle events, hottest sensor 33C, PSI 0.00 across the board, 31.1 GiB RAM, eno1 up at 1000 Mb/s, BIOS R21 02.26.00, no reboot pending. DRIVE HEALTH IS STILL UNKNOWN — the headline. SMART did not run, blocked TWICE over: no vaulted ansible_become_password for prod, AND smartmontools is not on the node at all (node_baseline has never run there). Power-on hours, wear, reallocated/pending sectors and drive temperature all remain unmeasured on a ~2011 consumer SSD. WARNs: kernel ioerr_cnt=6 on sda (read cautiously — four freshly attached iSCSI disks on dev each showed a flat 7, suggesting a fixed per-device discovery cost, not media trouble; attribution needs dmesg/root); 0.95 GiB swap still active (node_baseline does the swapoff); one IPv4 resolver only (the other two upstreams are IPv6 Google), which refines rather than resolves the DNS-SPOF finding. ESCALATION — ECC status is UNKNOWABLE on this hardware: /sys/devices/system/edac/mc exists but no controller is bound. Non-ECC consumer RAM. Silent memory corruption on a prod node would be undetectable. Recorded as a SKIP with reason, not a pass. Serves: R-ENV-01, R-ENV-03, R-OBS-04. Task: T-7.1. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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).
# 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, runslsblk/lscpu, and — only when it can escalate —smartctl. It writes nothing to the node and installs nothing.smartmontoolsis declared innode_baseline_packages; if it is absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31: package installation isnode_baseline's job, never a health check's, never a manualapt-get). - A skip is honest; a fake green is a defect. Every check is
PASS/WARN/FAIL/SKIP/INFO, and aSKIPalways 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 bothk3s-prod-1(HP EliteDesk, bare metal) andk3s-dev-1(Proxmox VM). - Never
becomes 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, power-on hours, lifetime remaining, reallocated + pending sectors, CRC errors, drive temperature — root only |
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
- A human summary printed in the play output.
- 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.
Known limits
- Without root: no SMART (wear, power-on hours, reallocated/pending sectors, 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 runningnode-baselinesosmartmontoolsexists — 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.
ioerr_cntcounts commands the kernel saw fail, which includes benign rejected or unsupported commands during discovery. Attribution needsdmesg, which needs root.