THE MODEL WAS WRONG AND MY CHECK WITH IT. A live counter-example: k3s-prod-2 after its power
cycle shows MEMATTR=0xbeeb0018 and 'Failed to map ... @ 0xbeeb0018' — the SAME address and SAME
line as both of its 2.6 GiB boots — with MemTotal 15857732 kB, i.e. 15.1 GiB of 16 GB. FULL
MEMORY. The check reported FAIL 'THIS NODE HAS LOST MEMORY TO THE BUG' on a healthy node while
memory.physical_total correctly PASSed at 4.2%. The two checks contradicted each other.
REFINED MECHANISM — two kernel functions were conflated:
efi_memattr_init() does the memblock_reserve(). Catastrophic loss needs tbl_size NEGATIVE
(signed int -> sign-extended -> clamped to PHYS_ADDR_MAX). A garbage-but-POSITIVE tbl_size
reserves a modest wrong amount and costs nothing noticeable.
efi_memattr_apply_permissions() does the later memremap(), and THAT is what prints
'Failed to map'. It fails for ANY sufficiently wrong size, negative or merely large-positive.
So the line proves the table was corrupt AND TRUSTED, not that memory was lost. The cost is set
by the garbage VALUE, which is why one address gave 2.6 GiB twice and 15.1 GiB once.
THIS ALSO REFUTES THE ADDRESS->OUTCOME HYPOTHESIS OUTRIGHT — not untested, disproven.
STRUCTURAL FIX so the contradiction cannot recur: the shortfall is computed ONCE into a shared
fact (node_health_mem_shortfall) with worst-wins sub-verdicts; memory.physical_total IS that
verdict and firmware.efi_memattr READS it, measuring nothing itself. FAIL is gated on the shared
verdict. 'Failed to map' + normal memory is now WARN ('the bug fired and this boot survived it').
No honesty guard was weakened; one was strengthened (the measurement clause falls back to the DIMM
shortfall when zoneinfo is unreadable, and only claims 'could NOT be measured' when neither was
read). All five paths incl. the two unproducible-live SKIPs exercised against synthetic facts.
Verdicts: prod-1 WARN, prod-2 WARN (was FAIL), prod-3 PASS, dev unaffected.
SECOND FAULT, arguably worse for prod: NODE 2 HANGS ON A WARM REBOOT, STAYING POWERED. Its
journal ends cleanly mid-shutdown ('eno1: NIC Link is Down') then stops; the box sat powered but
off-LAN for ~38 min until the operator held the power button. A node that cannot survive an
unattended warm reboot cannot be rolled in an HA cluster, and node_health cannot see a machine
that is off the network. Leading candidate: node 2 is the ONLY node with 'Recover OS after Boot
Failure = Enable' — promoted from 'coincidence' in the BIOS diff to a live availability defect.
The reboot survey is NOT viable unattended on node 2 until this is understood.
SURVEY RESULTS (40 boots): prod-1 0/20, prod-3 0/20, prod-2 0 ROUNDS (hung on its first reboot),
pooled 0/40 -> p(bad) 95% CI 0.000-0.088. NOT elimination: 0/20 still permits 17% per node, and
node 2's prior 2-of-3 is unchanged. What it did establish: addresses are node-specific and highly
concentrated (node 1 used 3 distinct addresses in 20 boots, 15 at one; no address ever shared
between nodes); MEMATTR -> parse was a PERFECT function across all 7 repeated addresses over 37
boots; and the corrupting bytes are stable PER MACHINE (node 1 always 1030, node 3 always
1463964749), so the 'shared OS boot chain' inference needs refining.
Adds playbooks/memattr-survey.yml + roles/memattr_survey — reboots opt-in, resumable, artifacts
gitignored. Documents an Ansible defect worth knowing repo-wide: a looped include_tasks under the
default 'linear' strategy is flattened per host, so hosts run SERIALLY; the first attempt rebooted
node 1 twenty times while the others idled. Fixed with strategy: free + a forks preflight assert.
Serves: R-ENV-01, R-ENV-03, R-OBS-04, R-TEST-02. 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> |
(not consulted) | WARN — the garbage version read > 2 so the kernel bailed out first: exposed, but this boot took the safe path |
[Firmware Bug]: Corrupted EFI Memory Attributes Table detected! |
(not consulted) | PASS — kernel >= 6.13 validated and rejected it: 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. 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.
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.