feat(ansible): node_health role — read-only hardware health for prod + dev nodes

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]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-08-31 23:20:25 -05:00
1 parent 677f9ea5f0
commit 9f59c36000
12 files changed
+1782

No files matched your search

+5
View File
@@ -1,3 +1,8 @@
# 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/
+30
View File
@@ -0,0 +1,30 @@
---
# 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, unlike the other playbooks here. Every existing node
# playbook (node-baseline.yml, k3s.yml, cilium.yml, kube-vip.yml, verify.yml) hardcodes
# `hosts: k3s_servers` / `k3s_primary` / `k3s_secondary`, which is exactly why they cannot
# select the prod groups today (the gap flagged in inventory/hosts.ini's prod section, and
# the R-ENV-02 reuse promise it threatens). A health check is the one play you want to be
# able to point anywhere, so it takes the pattern as a var and defaults to dev. 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
+91
View File
@@ -0,0 +1,91 @@
# `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, 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
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.
## 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 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.
- `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,78 @@
---
# 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).
min_nameservers: 2
# SMART (only evaluated when smartctl actually ran)
smart_reallocated_warn: 1
smart_reallocated_fail: 50
smart_pending_warn: 1
smart_pending_fail: 10
smart_lifetime_remaining_warn_pct: 20
smart_lifetime_remaining_fail_pct: 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.
node_health_smart_attribute_ids: [5, 9, 177, 179, 181, 182, 183, 184, 187, 188, 190,
194, 195, 196, 197, 198, 199, 202, 231, 232, 233,
241, 242, 246]
@@ -0,0 +1,630 @@
#!/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 `lsblk` (a read-only query). 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.
resolv = read_text("/etc/resolv.conf") or ""
nameservers = re.findall(r"^\s*nameserver\s+(\S+)", resolv, re.M)
return {"interfaces": interfaces, "nameservers": nameservers}
# --- 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,509 @@
---
# 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 ------------------------------------------------------------------------
- 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 was actually read.
s_health: >-
{{ 'SKIP' if (disk_smart.passed is not defined or disk_smart.passed is none)
else ('PASS' if disk_smart.passed else 'FAIL') }}
s_realloc: >-
{{ 'SKIP' if (disk_smart.reallocated_sectors is not defined or disk_smart.reallocated_sectors is not number)
else ('FAIL' if disk_smart.reallocated_sectors | int >= th.smart_reallocated_fail
else ('WARN' if disk_smart.reallocated_sectors | int >= th.smart_reallocated_warn else 'PASS')) }}
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')) }}
s_life: >-
{{ 'SKIP' if (disk_smart.lifetime_remaining_pct is not defined or disk_smart.lifetime_remaining_pct is not number)
else ('FAIL' if disk_smart.lifetime_remaining_pct | int <= th.smart_lifetime_remaining_fail_pct
else ('WARN' if disk_smart.lifetime_remaining_pct | int <= th.smart_lifetime_remaining_warn_pct 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_realloc, s_pending, s_life, s_temp]) | list | last)
| default('SKIP', true) }}
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('?'))
~ ', lifetime_remaining=' ~ (disk_smart.lifetime_remaining_pct | default('?')) ~ '%'
~ ', reallocated=' ~ (disk_smart.reallocated_sectors | default('?'))
~ ', pending=' ~ (disk_smart.pending_sectors | default('?'))
~ ', temp=' ~ (disk_smart.temperature_c | default('?')) ~ 'C') }}
- 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(', ')) }}
- 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': ('PASS' if (ns4 | length) >= (node_health_thresholds.min_nameservers | int) else 'WARN'),
'detail': ns_detail }] }}"
vars:
ansible_become: false
ns: "{{ node_health_facts.network.nameservers }}"
# 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 }}"
ns_detail: >-
{{ (ns | length) | string ~ ' nameserver(s) — IPv4: '
~ (ns4 | join(', ') | default('none', true))
~ '; IPv6: ' ~ (ns6 | join(', ') | default('none', true))
~ ('. Only ' ~ (ns4 | length) | string ~ ' IPv4 upstream: a single IPv4 upstream
already broke containerd image pulls and CoreDNS forwarding on dev, and the
fix is stranded on fix/node-dns-resilient-upstream (DECISIONS-LOG
2026-08-31, finding 5). 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,83 @@
---
# 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({}) }}"
# Normalised ("value") wear indicators, in vendor-preference order. Micron/Crucial use
# 202 Percent_Lifetime_Remaining; many others use 231 SSD_Life_Left or 233. The RAW
# value is kept alongside in `attributes` so nothing is hidden by this normalisation.
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) }}"
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) }}"
reallocated_sectors: "{{ smart_attrs | selectattr('id', 'equalto', 5) | map(attribute='raw.value') | first | default(none) }}"
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) }}"
crc_errors: "{{ smart_attrs | selectattr('id', 'equalto', 199) | map(attribute='raw.value') | first | default(none) }}"
reported_uncorrect: "{{ smart_attrs | selectattr('id', 'equalto', 187) | 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) }}"
# 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,147 @@
{# ===========================================================================
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 | default('?') }}% temp={{ sm.temperature_c | default('?') }}C
reallocated={{ sm.reallocated_sectors | default('?') }} pending={{ sm.pending_sectors | default('?') }} offline_uncorrectable={{ sm.offline_uncorrectable | default('?') }} crc_errors={{ sm.crc_errors | default('?') }}
{% 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) }}
{% 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 %}
nameservers {{ node_health_facts.network.nameservers | join(', ') | default('none configured', true) }}
--------------------------------------------------------------------------------
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 %}
================================================================================