Prod is being stood up now, without waiting for node 2's RAM upgrade. Three coupled
changes, all applied and verified on all three nodes.
=== 1. KERNEL >= 6.13 — now a PREREQUISITE, not a nice-to-have ===
A k3s SERVER that intermittently boots with 2.6 GiB is not a degraded node; it fails under
etcd plus its workload share, unpredictably, on any reboot. Fixed before k3s goes on.
roles/node_baseline gains a `kernel`-tagged block: asserts the suite matches the node's
Debian release, writes a deb822 trixie-backports source, installs, reads back the exact
version, writes a provenance/rollback note to the node. Scoped by
node_baseline_backports_kernel (default FALSE, true only in group_vars/k3s_prod) — chosen
over a hostname test so a fourth prod node inherits it from group membership. Verified:
--check against dev skips all 8 tasks on all 3 hosts. Two deliberate calls: `state: latest`
not `present` (linux-image-amd64 was already installed from main, so `present` would have
silently fixed nothing), and NO preferences.d pin (backports' NotAutomatic +
ButAutomaticUpgrades already gives 100/500; an explicit Pin-Priority: 100 would switch OFF
automatic security upgrades for the very kernel being installed).
Installed 7.1.8+deb13-amd64 on all three; 6.12.107 kept bootable.
THE DECISIVE PROOF: node 2 booted at MEMATTR=0xbeeb0018 — the EXACT address that gave it
2.6 GiB twice — and now reserves 634,904K instead of 13,958,980K. MemTotal 15.4 GiB. Same
address, handled correctly; not a lucky draw.
§0b's claim that a fixed kernel prints 'FW_BUG Corrupted...' is CONDITIONAL and was
corrected: >=6.13 has three rejection gates and only the third prints that line; all three
nodes hit gate 1 (version > 2). Proof instead is boot-independent — decompressing
/boot/vmlinuz on all three yields the FW_BUG and descriptor-size strings verbatim; neither
exists in 6.12.
TWO MORE BUGS IN MY OWN HEALTH CHECK, found because the fix couldn't otherwise be shown:
- MEMATTR_VERSION_RE matched (\d+) but the kernel prints that field with %d — SIGNED. Node
2's real value was -1569980416, which did not match, so state fell through to `clean` and
the node reported PASS "the kernel accepted it without complaint". A FALSE PASS on the
exact bug the check exists to find. Now (-?\d+).
- bad_version was WARN unconditionally, so a FIXED node would have stayed WARN forever.
Now keys on memattr_kernel_fixed: same line, WARN below 6.13, PASS at/above.
=== 2. DISK LAYOUT — Longhorn claimed, per-node paths DISCOVERED not hardcoded ===
Operator decision: reuse the ~424 GB partition; accept the shared-device I/O risk (dev's
etcd/Longhorn contention was on a spinning HDD, prod is SSD/NVMe).
Device paths differ per node (sda5 / nvme0n1p5 / sda5), so the role DISCOVERS the partition
via `findmnt --mountpoint` on a declared claim mountpoint rather than trusting inventory.
Per-host host_vars would have frozen today's hardware into inventory, where it goes stale on
the first re-image and the stale value's failure mode is mkfs on the WRONG partition.
Eleven fail-closed asserts before any write: real block device, not backing / /boot /usr
/var /home /tmp or active swap (built from the node's OWN gathered facts, never a hardcoded
list), already ext4, no foreign label, >= 100 GiB, and empty. Relabel, never reformat.
/srv verified empty first on every node (ls -A -> lost+found only; du 20K; the "5.1% used"
was ext4's default 5% root reserve).
=== 3. k3s DATA-DIR MOVED — /var was 11.8 GiB and would have killed etcd ===
Measured on the fully-deployed dev cluster: containerd 21-25 GB/node, /var total 58-70 GB.
Prod's /var is 11.8 GiB and held BOTH etcd and the image store, so prod would have filled
/var during its first image pulls — and a full /var means etcd cannot write its WAL. Cluster
death, not degradation, arriving during bring-up. Invisible when the disk decision was made,
because "etcd on root" sounded like the 30 GiB / rather than a separate 12 GiB /var.
Now: p5 -> /data (LABEL=k3s-data), with /data/longhorn and /data/k3s. Neutral names chosen
deliberately — the partition now has two tenants, and reusing /srv would make "claimed" and
"unclaimed" indistinguishable to the idempotency pivot, while a bare `data` label would make
a stranger's `data` volume look already-claimed and weaken an assert.
The transition off the existing LABEL=longhorn mount is DECLARED, not inferred
(node_baseline_data_retired_fs_label/_retired_mount, defaults ""), because changing the label
makes a claimed node answer "no" to the pivot and walk into the greenfield path. Self-retiring
once blkid -L k3s-data succeeds. THREE independent gates keep live dev out: retired vars
default ""; gated on longhorn_source == claim-mountpoint (dev is `device`); and the emptiness
assert fails closed on dev's 18 volumes even if both were defeated.
k3s_data_dir is one variable read by two roles (k3s_server --data-dir; node_baseline derives
the etcd mount). Role default is k3s's own default, so dev is unchanged and keeps its
dedicated etcd device. node_baseline_etcd_source becomes `shared-data-partition` and is
LOAD-BEARING: the role asserts per run that the filesystem backing the etcd dir really IS the
data partition — had the mount silently failed, etcd plus 21-25 GB of images would land back
on the OS filesystem invisibly until the disk filled.
LONGHORN_DATA_PATH is a new cluster-vars knob (R-ENV-02): base and DEV /var/lib/longhorn,
prod /data/longhorn.
DEV PROVEN UNCHANGED, which is the property that mattered — the live cluster has 18 Longhorn
volumes on that path. All 12 substituting Flux paths rendered through
`kubectl kustomize | flux envsubst --strict` for both trees: 12/12 per-file SHA-256 identical
and an identical aggregate SHA over every rendered dev byte. With a POSITIVE CONTROL showing
the same harness detects prod changing on exactly one line — an all-identical result is
worthless if the harness cannot see change. Live check after: dev's default-data-path setting
still /var/lib/longhorn, 18 volumes attached, 0 Kustomizations unready.
Idempotent: run 2 is changed=0 on all three nodes. Also fixed a real tag bug — include_tasks
does NOT propagate its own tags to included tasks (unlike import_tasks), so `--tags disks`
selected the include and ran nothing inside it while reporting success.
OPEN DEFECTS, tracked not forgotten: node 2's warm-reboot hang (one clean reboot since, NOT a
fix); and node 3 runs dhcpcd for its wifi interface, which rewrites /etc/resolv.conf on lease
renewal — the role's `contended` detection checks resolvconf and NetworkManager only, so node
3's decided resolvers will silently revert.
Gate: MERGE GATE: PASS. L0 substitution check 106 assertions.
Serves: R-ENV-01, R-ENV-02, R-ENV-03, R-STATE-01, R-STATE-02, R-PLAT-01. 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. Three things need root: SMART, the kernel ring buffer (dmesg, because Debian setskernel.dmesg_restrict=1) and the SMBIOS DIMM inventory (dmidecode -t 17). Everything else is read unprivileged in one pass, and the privileged second pass re-runs the same collector with--privilegedso the parsing has one home. Without escalation those checks SKIP with the exact reason.
What it checks
| id | what |
|---|---|
access.privilege |
whether become works at all (gates the root-only checks) |
storage.smart.<disk> |
overall-health, normalised-vs-threshold trips, pending/uncorrectable/CRC counters, lifetime remaining, wear + endurance context, drive temperature — root only. See How the SMART verdict is reached |
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 |
memory.physical_total |
is this machine using the RAM it has? /proc/zoneinfo present-vs-managed pages (no root, metal or VM) and MemTotal vs the DIMM capacity SMBIOS reports (dmidecode -t 17, root, bare metal). Cause-independent by design — see The EFI memattr check |
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 |
firmware.efi_memattr |
did the corrupt-EFI-Memory-Attributes-Table bug fire on this boot, and did it cost this node memory (per the measurement memory.physical_total reports) — root only. See The EFI memattr check |
Thresholds live in defaults/main.yml (node_health_thresholds); no verdict is hardcoded
in task logic.
The EFI memattr check
infra/PROD-PROVISIONING.md §0b is authoritative; this is only what the check needs.
HP's R21 firmware publishes a corrupt EFI Memory Attributes Table, and Linux < 6.13 does not validate it before reserving memory from its contents. Two different kernel functions matter, and the check used to conflate them:
efi_memattr_init()computestbl_size = sizeof(*tbl) + num_entries * desc_size— both fields unvalidated — andmemblock_reserve()s it.tbl_sizeis a signedint, so catastrophic loss requires it to be NEGATIVE: bit 31 set sign-extends throughphys_addr_tandmemblock_cap_size()clamps it toPHYS_ADDR_MAX, reserving everything above the table. A garbage-but-positivetbl_sizereserves a modest, wrong amount and costs nothing measurable.efi_memattr_apply_permissions()latermemremap()s the table. This is what prints "Failed to map", and it fails for any sufficiently wrongtbl_size— negative or merely large.
So Failed to map proves the table was corrupt and trusted by the kernel. It does not
prove memory was lost: that depends on the garbage value at that address on that boot,
not on the address. k3s-prod-2 lost 13.3 GiB twice at 0xbeeb0018 and then (2026-09-03)
booted with all 15.1 GiB — same address, same log line. The verdict is therefore based on
measured memory, never on the log line alone:
| kernel log | measured shortfall | verdict |
|---|---|---|
efi: memattr: Failed to map EFI Memory Attributes table @ 0x… |
large (memory.physical_total FAIL) |
FAIL — the bug fired and memory is lost now, measured |
efi: memattr: Failed to map EFI Memory Attributes table @ 0x… |
normal | WARN — the bug fired and the kernel trusted a corrupt table; this boot drew a survivable garbage value, the next may not |
efi: memattr: Unexpected EFI Memory Attributes table version <N>, kernel < 6.13 |
(not consulted) | WARN — the garbage version read outside 0..2 so the kernel bailed out first: exposed, but this boot took the safe path |
efi: memattr: Unexpected EFI Memory Attributes table version <N>, kernel >= 6.13 |
(not consulted) | PASS — the same bail-out, but now the FIRST of three validation gates: no draw can reach the fatal arithmetic |
[Firmware Bug]: Corrupted EFI Memory Attributes Table detected! |
(not consulted) | PASS — kernel >= 6.13 rejected it at the num_entries > 64K gate: this is the fix, working |
| no memattr line, log reaches back to boot | (not consulted) | PASS |
| no memattr line, log does not reach back to boot | — | SKIP — the ring buffer wrapped past early boot: unknown, not unaffected |
dmesg unreadable |
— | SKIP — no root / dmesg_restrict=1: unknown, not unaffected |
The WARN is the point on a kernel < 6.13. All three prod nodes publish the same corrupt table and differ only by luck, boot to boot. A node whose kernel just trusted a corrupt table is not passing a health check even when it kept its RAM.
The FW_BUG line is proof, but its ABSENCE proves nothing
efi_memattr_init() on >= 6.13 has three gates, and only the third prints the FW_BUG
line:
| gate | message | reachable when |
|---|---|---|
tbl->version > 2 |
Unexpected EFI Memory Attributes table version %d |
the garbage lands in the version field |
implausible desc_size |
Unexpected EFI Memory Attributes descriptor size %u (expected: %lu) |
version reads 0..2, desc_size does not |
num_entries > SZ_64K |
[Firmware Bug]: Corrupted EFI Memory Attributes Table detected! (version == %u, desc_size == %u, num_entries == %u) |
version and desc_size read plausibly, entry count does not |
All three are rejections. Which one a boot prints is decided by which field that boot's
garbage corrupts — it is not a choice between "fixed" and "not fixed". So a fixed kernel very
often prints the first message, and looking only for the FW_BUG line will make a working
fix look absent. On 2026-09-04 all three prod nodes came up on 7.1.8 and all three printed
the version line, not the FW_BUG one. That is why the verdict keys on the kernel version
alongside the log line, and why the check must never demand the FW_BUG string as its
evidence. Positive, boot-independent proof that the fixed code is compiled in is a string in
the kernel image itself, not a line in dmesg.
The version field is printed with %d — signed. Garbage with bit 31 set is logged
negative (version -1569980416, k3s-prod-2, 2026-09-04). The collector regex missed that
until 2026-09-04 and such nodes fell through to clean, i.e. a kernel that had just rejected
a corrupt table was reported as one that "accepted it without complaint" — a false PASS on
the exact bug this check exists to find.
Fix: boot a kernel >= 6.13 (Debian 13: linux-image-amd64 from
trixie-backports); upstream commit 8fbe4c49c0cc. There is no kernel cmdline
workaround — efi_memattr_init() runs before any efi= bail-out could take effect, and
rebooting/CMOS/BIOS/USB changes only move where the table lands.
The two memory checks cannot contradict each other
memory.physical_total and firmware.efi_memattr both need the answer to "did this machine
lose memory?", and it is measured once — /proc/zoneinfo present-vs-managed pages plus
the SMBIOS-vs-MemTotal shortfall — into the shared node_health_mem_shortfall fact in
tasks/assess.yml. memory.physical_total is that verdict; firmware.efi_memattr reads
it and may only escalate to FAIL when it says FAIL. The memattr check measures no memory of
its own, so there is exactly one verdict on lost memory per host and both checks quote it.
A node whose kernel log cannot be read is still measured by memory.physical_total, and a
future memory loss with a different cause is still caught.
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.
How the SMART verdict is reached
SMART raw values are vendor-defined and this role does not judge on them. smartctl(8) is explicit: "starting with ATA/ATAPI-4, revision 4, the meaning of these Attribute fields has been made entirely vendor-specific."
This is not theoretical. The first real run of this role FAILED k3s-prod-1's
Crucial/Micron C400 (C400-MTFDDAC512MAM, fw 070H) on reallocated=61440, read as a
plain bad-sector count. Micron's own TN-FD-20 SMART Attributes for the C400 SSD defines
attribute 5 not as a sector count but as Reallocated Block Count, whose raw is "the
grown NAND Flash bad-block count multiplied by the Block_Sector_Count, which is 256 x 8 for
64Gb NAND ... or 256 x 16 for 128Gb NAND (256GB and 512GB)". For this 512GB drive that
multiplier is 256 x 16 = 4096, and 61440 / 4096 = 15 — corroborated on the same drive by
attribute 170 Grown_Failing_Block_Ct = 15 and attribute 196 Reallocated_Event_Count = 15.
Fifteen grown bad blocks, not sixty-one thousand bad sectors. The verdict was wrong by a
factor of 4096 because it assumed a unit the ATA spec does not guarantee.
What the check judges on instead
Vendor-independent signals only, each of which means the same thing on every drive:
- The drive's own overall-health self-assessment (
smart_status.passed) — FAIL. - Normalised VALUE vs the MANUFACTURER'S OWN THRESHOLD, taken from smartctl's
when_failedfield ("now"/"past"/""). Per smartctl(8),FAILING_NOWmeans the normalised value is at or below the threshold the manufacturer set for that attribute on that model; a dash means it is OK now and has never failed. This needs no per-vendor knowledge from us — the vendor already encoded the answer. Pre-fail failing now → FAIL; Old_age failing now, or anything that failed in the past → WARN. - ATA-standard error counters whose raw meaning is a plain event count everywhere: 197 pending, 198 offline-uncorrectable, 187 reported-uncorrectable → WARN/FAIL; 199 CRC (SATA link, not media) → WARN only.
- NVMe's structured health log —
critical_warning,media_errors,percentage_used. - Wear from the NORMALISED lifetime indicator (ATA 202/231/233
value, or NVMepercentage_used) — a percentage by definition, and therefore portable where raw wear counts are not.
What is reported but never produces a FAIL
Vendor raw counts are real information and are all printed with their numbers — grown bad-block count, reallocation events (196), erase/program failures, unexpected power losses, and attribute 5's raw shown beside the normalised reading that contradicts it. They can raise a WARN, never a FAIL, because their units are vendor-defined.
The endurance counters live in the vendor-specific attribute-ID range (170-174), so they are
matched by the name smartctl's drive database resolved for that model, not by a bare ID:
drivedb.h is smartmontools' own per-(model, firmware) translation, so keying off its
resolved name borrows that portability instead of hardcoding one vendor's layout. On a drive
drivedb does not recognise, the name is Unknown_Attribute, nothing matches, and the field
stays null — no claim is made either way.
Two honesty traps this check does not fall into
- Zero is not a clean bill of health. Micron TN-FD-20 states attribute 197 on the C400 "will always be 0, as reallocation will be done on-the-fly". A non-zero 197 is a finding; a zero one proves nothing. The role reports the number and does not infer health from it.
- A quiet drive is not necessarily a good drive.
overall-health=PASSEDwith every threshold un-tripped can still sit alongside a third of rated life consumed, grown bad blocks, erase failures and unprotected power losses — which is exactlyk3s-prod-1. Those produce a WARN with the numbers, because a check that called that drive "fine" would be worse than the false FAIL it replaced.
Known limits
- Without root: no SMART (wear, power-on hours, error counters, drive
temperature) and no DMI serial numbers. On prod that means creating
inventory/group_vars/k3s_prod/vault.yml(see that directory's README) and 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.
- SMART wear and endurance context depends on smartmontools'
drivedb.hknowing the drive. On an unrecognised model the vendor attributes areUnknown_Attribute; the portable signals (overall health, normalised-vs-threshold, 197/198/187/199, NVMe log) still apply, but the wear/endurance context SKIPs rather than guessing. ioerr_cntcounts commands the kernel saw fail, which includes benign rejected or unsupported commands during discovery. Attribution needsdmesg, which needs root.