Files
home-network-k3s-impl/infra/ansible/roles/node_health/tasks/assess.yml
T
jakman85andClaude Opus 5 9d00a3fcd3 fix(node_health): memory.swap blamed the wrong thing, and could not see the defect
The WARN read "roles/node_baseline runs swapoff and neutralises the fstab entry;
this node has not been baselined yet". That was a MIS-DIAGNOSIS. All three prod
nodes HAD been baselined, had their fstab swap line commented out by the role, and
were still swapping 977 MiB each after a reboot. Anyone reading that WARN would
have re-run the role that had already run, and watched swap come back next boot.

The detail now names the actual mechanism — systemd-gpt-auto-generator re-creating
a .swap unit from the partition TYPE GUID 0657fd6d-a4ab-43c4-84e5-0933c84b4f4f at
every boot, without reading /etc/fstab — says in as many words that this is not
"the node has not been baselined", and points at `node-baseline.yml --tags system`.

The check was also blind in the other direction: SwapTotal == 0 is not evidence
that swap is disabled, only that nothing has been swapped on yet this boot, so a
node freshly swapoff-ed PASSed while still set to swap again on reboot — exactly
the state the old role left behind. The collector now reports memory.swap_persistence
(swap.target masked?, .swap units the generators emitted this boot, which of them
are masked, leftover active fstab swap lines — all world-readable, no root, no
systemctl parsing), and the check WARNs on "clean now, swapping after the next
reboot".

Rendered against every fact shape (swap active + unmasked, swap off + unmasked,
after the fix, a dev VM with no swap partition at all, per-unit mask only,
persistence unreadable, an older collector's output with the key absent, and
/proc/meminfo unreadable) and verified live on k3s-prod-1 post-reboot:
  [PASS] memory.swap — no swap active and none can be activated: swap.target is masked

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 22:35:31 -05:00

1082 lines
65 KiB
YAML

---
# Turn raw facts into explicit, named checks. Every check is one of:
#
# PASS measured, and within threshold
# WARN measured, outside the warn threshold — worth a human look
# FAIL measured, outside the fail threshold — do not put production state here
# SKIP NOT measured, with the reason attached (missing root, missing tool, sensor not
# present on this hardware). A SKIP is an honest "unknown" and is NEVER rendered
# as a pass — this repo's discipline is absolute on that point.
# INFO measured, no pass/fail semantics (inventory: firmware, uptime, CPU scaling)
#
# All thresholds come from node_health_thresholds (defaults/main.yml) so retuning never
# means editing logic here.
- name: Derive intermediate values used by several checks
ansible.builtin.set_fact:
# Every temperature the box exposes, from both thermal zones and hwmon chips.
# `select('number')` drops any sensor that returned null (unreadable/absent).
node_health_temps_c: >-
{{ ((node_health_facts.thermal.zones | map(attribute='temp_c') | list)
+ (node_health_facts.thermal.hwmon | map(attribute='temps') | flatten
| map(attribute='temp_c') | list)) | select('number') | list }}
# Filesystems, worst-first by percentage used / inodes used.
node_health_mounts_by_use: >-
{{ node_health_facts.storage.mounts | selectattr('used_pct', 'number')
| sort(attribute='used_pct') | reverse | list }}
node_health_mounts_by_inode: >-
{{ node_health_facts.storage.mounts | selectattr('inodes_used_pct', 'number')
| sort(attribute='inodes_used_pct') | reverse | list }}
# Physical NICs that are actually up. Virtual plumbing was already excluded by the
# collector; `virtual` here catches bonds/VLANs the regex let through.
node_health_nics_up: >-
{{ node_health_facts.network.interfaces
| selectattr('operstate', 'equalto', 'up') | list }}
# Devices that could actually BECOME a dedicated Longhorn or etcd volume: no
# partitions, no whole-device filesystem, nothing mounted from them. All three
# conditions matter — node_baseline formats the data disks with no partition table at
# all (mkfs straight onto /dev/vdb, mounted by LABEL), so "no partitions" alone would
# nominate a node's live Longhorn disk for reformatting. iSCSI/FC volumes were already
# split out by the collector for the same reason. On a single-disk node this list is
# empty — precisely the finding that blocks PROD-PROVISIONING.md §2.
node_health_free_devices: >-
{{ node_health_facts.storage.disks
| selectattr('partitions', 'equalto', [])
| selectattr('fstype', 'none')
| selectattr('mountpoints', 'equalto', [])
| list }}
vars:
ansible_become: false
- name: Assess — privilege escalation (gates every root-only check below)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'access.privilege',
'title': 'Privilege escalation (become) for root-only checks',
'status': ('INFO' if node_health_root_available | bool else 'SKIP'),
'detail': node_health_root_skip_reason }] }}"
# --- Storage ------------------------------------------------------------------------
# SMART VERDICTS — READ defaults/main.yml's SMART threshold block BEFORE EDITING THIS.
# Short version: this check judges a drive on the signals smartctl(8) itself defines as
# vendor-independent (the drive's own overall-health verdict; NORMALISED value vs the
# MANUFACTURER'S OWN threshold, via smartctl's `when_failed`; and the ATA-standard error
# counters 197/198/187/199 whose raw meaning is a plain event count everywhere, plus NVMe's
# structured health log). Vendor raw counts are reported with their numbers and can raise a
# WARN, but they can never produce a FAIL — because their units are vendor-defined and
# reading one as if it were a portable sector count is exactly the defect this replaces.
- name: Assess — SMART per physical disk
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'storage.smart.' ~ item.kname,
'title': 'SMART health — ' ~ item.path ~ ' (' ~ (item.model | default('unknown model')) ~ ')',
'status': smart_status,
'detail': smart_detail }] }}"
loop: "{{ node_health_facts.storage.disks }}"
loop_control:
label: "{{ item.path }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
disk_smart: "{{ (node_health_smart | selectattr('path', 'equalto', item.path) | list | first) | default({}) }}"
# --- Sub-verdicts. Each is SKIP unless the number behind it was actually read; a SKIP is
# an honest "unknown" and never contributes a PASS. ------------------------------------
# 1. The drive's own overall-health self-assessment. Vendor-independent by construction.
s_health: >-
{{ 'SKIP' if (disk_smart.passed is not defined or disk_smart.passed is none)
else ('PASS' if disk_smart.passed else 'FAIL') }}
# 2. NVMe critical warning bitfield (spare below threshold, media in read-only, etc.).
s_nvme_crit: >-
{{ 'SKIP' if (disk_smart.nvme_critical_warning is not defined or disk_smart.nvme_critical_warning is not number)
else ('PASS' if disk_smart.nvme_critical_warning | int == 0 else 'FAIL') }}
# 3. THE portable per-attribute failure signal: normalised value at or below the
# threshold the MANUFACTURER set for that attribute on that model. Pre-fail failing
# now = FAIL. Old_age failing now, or anything that failed in the past, = WARN.
s_thresh: >-
{{ 'SKIP' if not (disk_smart.thresholds_present | default(false))
else ('FAIL' if (disk_smart.failing_now_prefail | default([]) | length) > 0
else ('WARN' if ((disk_smart.failing_now | default([]) | length) > 0
or (disk_smart.failed_past | default([]) | length) > 0)
else 'PASS')) }}
# 4. ATA-standard hard-error counters. 197 pending; 198/187 + NVMe media_errors are
# uncorrectable reads — data the drive could NOT return. Same meaning on every drive.
s_pending: >-
{{ 'SKIP' if (disk_smart.pending_sectors is not defined or disk_smart.pending_sectors is not number)
else ('FAIL' if disk_smart.pending_sectors | int >= th.smart_pending_fail
else ('WARN' if disk_smart.pending_sectors | int >= th.smart_pending_warn else 'PASS')) }}
uncorr_values: >-
{{ [disk_smart.offline_uncorrectable | default(none),
disk_smart.reported_uncorrect | default(none),
disk_smart.nvme_media_errors | default(none)] | select('number') | list }}
s_uncorr: >-
{{ 'SKIP' if (uncorr_values | length) == 0
else ('FAIL' if (uncorr_values | max) >= th.smart_uncorrectable_fail
else ('WARN' if (uncorr_values | max) >= th.smart_uncorrectable_warn else 'PASS')) }}
# 5. 199 CRC — SATA link integrity (cable/backplane), not media. WARN only: the counter
# never resets, so one historic bad seating must not condemn the node forever.
s_crc: >-
{{ 'SKIP' if (disk_smart.crc_errors is not defined or disk_smart.crc_errors is not number)
else ('WARN' if disk_smart.crc_errors | int >= th.smart_crc_warn else 'PASS') }}
# 6. Wear, from the NORMALISED lifetime indicator — a percentage by definition.
life_rem: "{{ disk_smart.lifetime_remaining_pct | default(none) }}"
life_used: "{{ (100 - (life_rem | int)) if (life_rem is number) else none }}"
s_life: >-
{{ 'SKIP' if (life_rem is not number)
else ('FAIL' if life_rem | int <= th.smart_lifetime_remaining_fail_pct
else ('WARN' if (life_rem | int <= th.smart_lifetime_remaining_warn_pct
or life_used | int >= th.smart_life_used_pct_warn)
else 'PASS')) }}
# 7. Reallocation ACTIVITY. Portable evidence first (196 event count, and the grown
# bad-block counter smartctl's drivedb named for this model). Only if neither exists
# do we fall back to attribute 5's raw — and then ONLY to say "nonzero, so something
# was remapped", which is portable, never to interpret its magnitude, which is not.
remap_evidence: >-
{{ [disk_smart.realloc_events | default(none),
disk_smart.grown_bad_blocks | default(none)] | select('number') | list }}
realloc_raw: "{{ (disk_smart.realloc_attr | default({}, true)).get('raw', {}).get('value', none) }}"
s_remap: >-
{{ ('WARN' if (remap_evidence | max) >= th.smart_realloc_events_warn else 'PASS')
if (remap_evidence | length) > 0
else (('WARN' if realloc_raw | int > 0 else 'PASS') if realloc_raw is number else 'SKIP') }}
# 8. Endurance stress — erase/program failures are NAND wearing out under the FTL.
s_erase: >-
{{ 'SKIP' if (disk_smart.erase_fail_count is not defined or disk_smart.erase_fail_count is not number)
else ('WARN' if disk_smart.erase_fail_count | int >= th.smart_erase_fail_warn else 'PASS') }}
s_program: >-
{{ 'SKIP' if (disk_smart.program_fail_count is not defined or disk_smart.program_fail_count is not number)
else ('WARN' if disk_smart.program_fail_count | int >= th.smart_program_fail_warn else 'PASS') }}
# 9. Unexpected power loss. On a consumer SSD with no power-loss protection each one is
# an opportunity for a torn or lost in-flight write — which is precisely the guarantee
# etcd's fsync contract depends on. Directly relevant to PROD-PROVISIONING.md Section 2.
s_ploss: >-
{{ 'SKIP' if (disk_smart.unexpected_power_loss is not defined or disk_smart.unexpected_power_loss is not number)
else ('WARN' if disk_smart.unexpected_power_loss | int >= th.smart_unexpected_power_loss_warn else 'PASS') }}
s_temp: >-
{{ 'SKIP' if (disk_smart.temperature_c is not defined or disk_smart.temperature_c is not number)
else ('FAIL' if disk_smart.temperature_c | int >= th.smart_temp_fail_c
else ('WARN' if disk_smart.temperature_c | int >= th.smart_temp_warn_c else 'PASS')) }}
# Worst sub-verdict wins. `select('in', ...)` preserves the order of the left-hand
# list, so the LAST survivor is the most severe present.
smart_status: >-
{{ (['PASS', 'WARN', 'FAIL'] | select('in', [s_health, s_nvme_crit, s_thresh, s_pending,
s_uncorr, s_crc, s_life, s_remap, s_erase, s_program, s_ploss, s_temp]) | list | last)
| default('SKIP', true) }}
# --- Detail. Every WARN/FAIL must arrive WITH the number that caused it; a verdict the
# operator cannot check is not much better than a wrong one. -------------------------
smart_findings: >-
{%- set n = namespace(l=[]) -%}
{%- if s_health == 'FAIL' -%}
{%- set _ = n.l.append('the drive reports its OWN overall-health self-assessment as FAILED') -%}
{%- endif -%}
{%- if s_nvme_crit == 'FAIL' -%}
{%- set _ = n.l.append('NVMe critical_warning=' ~ disk_smart.nvme_critical_warning ~ ' (non-zero)') -%}
{%- endif -%}
{%- for a in (disk_smart.failing_now_prefail | default([])) -%}
{%- set _ = n.l.append('Pre-fail attribute ' ~ a.id ~ ' ' ~ a.name ~ ' is AT OR BELOW its manufacturer threshold NOW (value='
~ a.value ~ ', thresh=' ~ a.thresh ~ ')') -%}
{%- endfor -%}
{%- for a in (disk_smart.failed_past | default([])) -%}
{%- set _ = n.l.append('attribute ' ~ a.id ~ ' ' ~ a.name ~ ' crossed its manufacturer threshold IN THE PAST (worst='
~ a.worst ~ ', thresh=' ~ a.thresh ~ ')') -%}
{%- endfor -%}
{%- if s_pending in ['WARN', 'FAIL'] -%}
{%- set _ = n.l.append('current pending sectors (197)=' ~ disk_smart.pending_sectors) -%}
{%- endif -%}
{%- if s_uncorr in ['WARN', 'FAIL'] -%}
{%- set _ = n.l.append('UNCORRECTABLE read errors — offline(198)=' ~ (disk_smart.offline_uncorrectable if disk_smart.offline_uncorrectable is number else 'n/a')
~ ', reported(187)=' ~ (disk_smart.reported_uncorrect if disk_smart.reported_uncorrect is number else 'n/a')
~ ', nvme media_errors=' ~ (disk_smart.nvme_media_errors if disk_smart.nvme_media_errors is number else 'n/a')) -%}
{%- endif -%}
{%- if s_crc == 'WARN' -%}
{%- set _ = n.l.append('SATA link CRC errors (199)=' ~ disk_smart.crc_errors ~ ' — cable/backplane, not media') -%}
{%- endif -%}
{%- if s_life in ['WARN', 'FAIL'] -%}
{%- set _ = n.l.append(life_used ~ '% of rated life CONSUMED (' ~ life_rem
~ '% remaining, from the normalised wear indicator)') -%}
{%- endif -%}
{%- if s_remap == 'WARN' and (remap_evidence | length) > 0 -%}
{%- set _ = n.l.append((remap_evidence | max) ~ ' grown bad block(s)/reallocation event(s)'
~ (' [196 events=' ~ disk_smart.realloc_events ~ ']' if disk_smart.realloc_events is number else '')
~ (' [grown failing blocks=' ~ disk_smart.grown_bad_blocks ~ ']' if disk_smart.grown_bad_blocks is number else '')) -%}
{%- elif s_remap == 'WARN' -%}
{%- set _ = n.l.append('the drive reports a non-zero reallocation attribute (5 raw=' ~ realloc_raw
~ '); its UNIT is vendor-defined, so the count is not interpreted here') -%}
{%- endif -%}
{%- if s_erase == 'WARN' -%}
{%- set _ = n.l.append('NAND erase failures=' ~ disk_smart.erase_fail_count) -%}
{%- endif -%}
{%- if s_program == 'WARN' -%}
{%- set _ = n.l.append('NAND program failures=' ~ disk_smart.program_fail_count) -%}
{%- endif -%}
{%- if s_ploss == 'WARN' -%}
{%- set _ = n.l.append('unexpected power losses=' ~ disk_smart.unexpected_power_loss
~ ' — a consumer SSD has no power-loss protection, so each one risks a torn or lost in-flight write') -%}
{%- endif -%}
{%- if s_temp in ['WARN', 'FAIL'] -%}
{%- set _ = n.l.append('temperature=' ~ disk_smart.temperature_c ~ 'C') -%}
{%- endif -%}
{{ n.l | join('; ') }}
# The raw value that started this. Shown ALWAYS when it is non-zero and contradicts the
# normalised reading, explicitly labelled as vendor-encoded and explicitly excluded from
# the verdict — so the next operator to see 61440 sees why it is not 61440 bad sectors.
realloc_raw_note: >-
{%- if realloc_raw is number and realloc_raw | int > 0 -%}
{%- set a = disk_smart.realloc_attr -%}
attribute 5 ({{ a.name }}) raw={{ realloc_raw }} normalised value={{ a.value }} vs thresh={{ a.thresh }}
{%- if (remap_evidence | length) > 0 and (remap_evidence | max) > 0
and realloc_raw | int != (remap_evidence | max) | int
and realloc_raw | int % (remap_evidence | max) | int == 0 -%}
{{ ' (= ' ~ (remap_evidence | max) ~ ' x ' ~ (realloc_raw | int // (remap_evidence | max) | int) ~ ')' }}
{%- endif -%}
. SMART raw values are VENDOR-DEFINED (smartctl(8): "entirely vendor-specific") and this
one is NOT a portable bad-sector count — it is reported as context and is NOT used for
the verdict. The normalised value/threshold pair beside it is the manufacturer's own
trip point and IS.
{%- endif -%}
smart_detail: >-
{{ ('not read — ' ~ node_health_root_skip_reason)
if not (node_health_root_available | bool)
else ('not read — smartmontools is not installed on this node; it ships with
roles/node_baseline (node_baseline_packages), which has not been run here.
This role never installs packages.')
if not (node_health_facts.tools.smartctl is truthy)
else ('smartctl ran but this device exposes no SMART data ('
~ (disk_smart.messages | default([]) | join('; ')) ~ ') — normal for virtio/VM disks')
if not (disk_smart.available | default(false))
else ('overall-health=' ~ ('PASSED' if disk_smart.passed else 'FAILED')
~ ', power_on_hours=' ~ (disk_smart.power_on_hours | default('?'))
~ ', power_cycles=' ~ (disk_smart.power_cycles | default('?'))
~ ', life_remaining=' ~ ((life_rem | string ~ '%') if life_rem is number else 'not reported')
~ ', pending(197)=' ~ (disk_smart.pending_sectors if disk_smart.pending_sectors is number else 'n/a')
~ ', offline_uncorrectable(198)=' ~ (disk_smart.offline_uncorrectable if disk_smart.offline_uncorrectable is number else 'n/a')
~ ', reported_uncorrect(187)=' ~ (disk_smart.reported_uncorrect if disk_smart.reported_uncorrect is number else 'n/a')
~ ', crc(199)=' ~ (disk_smart.crc_errors if disk_smart.crc_errors is number else 'n/a')
~ (('. NO attribute is at or below its manufacturer threshold; closest is '
~ disk_smart.prefail_closest.id ~ ' ' ~ disk_smart.prefail_closest.name
~ ' value=' ~ disk_smart.prefail_closest.value
~ ' vs thresh=' ~ disk_smart.prefail_closest.thresh)
if (s_thresh == 'PASS' and (disk_smart.prefail_closest | default(none)) is mapping) else '')
~ (('. FINDINGS: ' ~ smart_findings) if smart_findings | trim else '')
~ (('. CONTEXT: ' ~ realloc_raw_note) if realloc_raw_note | trim else '')) }}
- name: Assess — kernel-visible I/O errors per disk (no root needed)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'storage.io_errors',
'title': 'Kernel I/O error counters on physical disks (SCSI/ATA layer)',
'status': io_status,
'detail': io_detail }] }}"
vars:
ansible_become: false
# ioerr_cnt/iotmo_cnt are readable WITHOUT root. They are not a substitute for SMART
# (no wear, no power-on hours, no reallocated sectors) but they are a real signal: a
# non-zero count means the kernel actually saw failed or timed-out commands on that
# device since boot.
counted: "{{ node_health_facts.storage.disks | selectattr('scsi_counters', 'mapping') | list }}"
err_total: >-
{{ (counted | map(attribute='scsi_counters.ioerr_cnt') | select('number') | list | sum)
+ (counted | map(attribute='scsi_counters.iotmo_cnt') | select('number') | list | sum) }}
io_status: >-
{{ 'SKIP' if (counted | length) == 0
else ('PASS' if err_total | int == 0 else 'WARN') }}
io_sum_done: "{{ counted | map(attribute='scsi_counters.iodone_cnt') | select('number') | list | sum }}"
io_sum_err: "{{ counted | map(attribute='scsi_counters.ioerr_cnt') | select('number') | list | sum }}"
io_sum_tmo: "{{ counted | map(attribute='scsi_counters.iotmo_cnt') | select('number') | list | sum }}"
# Aggregate here; the STORAGE section of the summary prints the per-disk counters.
io_detail: >-
{{ 'no disk exposes SCSI/ATA error counters (virtio or NVMe-only node)'
if (counted | length) == 0
else ((counted | map(attribute='kname') | join(', ')) ~ ': completed='
~ io_sum_done | string ~ ', errors=' ~ io_sum_err | string
~ ', timeouts=' ~ io_sum_tmo | string
~ ('. Non-zero errors are commands the kernel saw fail on that device
since boot. They are NOT proof of media failure (a rejected or
unsupported command during discovery counts too) and they cannot be
attributed without dmesg, which needs root.'
if err_total | int > 0 else '')) }}
- name: Assess — filesystem free space
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'storage.capacity',
'title': 'Filesystem free space (worst mount)',
'status': fs_status,
'detail': fs_detail }] }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
worst: "{{ (node_health_mounts_by_use | first) | default({}) }}"
fs_status: >-
{{ 'SKIP' if (node_health_mounts_by_use | length) == 0
else ('FAIL' if worst.used_pct >= th.fs_used_pct_fail
else ('WARN' if worst.used_pct >= th.fs_used_pct_warn else 'PASS')) }}
fs_detail: >-
{{ 'no block-backed filesystem found' if (node_health_mounts_by_use | length) == 0
else (worst.mountpoint ~ ' ' ~ worst.used_pct | string ~ '% used ('
~ (worst.avail_bytes | int / 1073741824) | round(1) | string ~ ' GiB free of '
~ (worst.size_bytes | int / 1073741824) | round(1) | string ~ ' GiB)') }}
- name: Assess — filesystem inode headroom
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'storage.inodes',
'title': 'Filesystem inode headroom (worst mount)',
'status': in_status,
'detail': in_detail }] }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
worst: "{{ (node_health_mounts_by_inode | first) | default({}) }}"
in_status: >-
{{ 'SKIP' if (node_health_mounts_by_inode | length) == 0
else ('WARN' if worst.inodes_used_pct >= th.inode_used_pct_warn else 'PASS') }}
in_detail: >-
{{ 'no filesystem reported inode usage' if (node_health_mounts_by_inode | length) == 0
else (worst.mountpoint ~ ' ' ~ worst.inodes_used_pct | string ~ '% of inodes used') }}
- name: Assess — data-disk layout (feeds PROD-PROVISIONING.md §2)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'storage.layout',
'title': 'Physical disks and spare block devices for Longhorn/etcd',
'status': 'INFO',
'detail': layout_detail }] }}"
vars:
ansible_become: false
disks: "{{ node_health_facts.storage.disks }}"
attached: "{{ node_health_facts.storage.attached_volumes | default([]) }}"
layout_detail: >-
{{ (disks | length) | string ~ ' physical disk(s): '
~ (disks | map(attribute='kname') | join(', ')) ~ '. '
~ ((attached | length) | string ~ ' attached iSCSI/FC volume(s) excluded ('
~ (attached | map(attribute='kname') | join(', ')) ~ '). '
if (attached | length) > 0 else '')
~ ((node_health_free_devices | length) | string ~ ' device(s) with no partitions ('
~ (node_health_free_devices | map(attribute='kname') | join(', ')) ~ ')'
if (node_health_free_devices | length) > 0
else 'NO unpartitioned spare device — node_baseline_longhorn_device /
node_baseline_etcd_device have nothing to claim without repartitioning
or adding a drive') }}
# --- Memory -------------------------------------------------------------------------
- name: Assess — memory headroom
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'memory.available',
'title': 'Available memory',
'status': mem_status,
'detail': mem_detail }] }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
m: "{{ node_health_facts.memory }}"
avail_pct: >-
{{ ((m.mem_available_kb | int) * 100.0 / (m.mem_total_kb | int)) | round(1)
if (m.mem_total_kb is number and m.mem_available_kb is number and m.mem_total_kb | int > 0)
else none }}
mem_status: >-
{{ 'SKIP' if avail_pct is not number
else ('FAIL' if avail_pct | float <= th.mem_available_pct_fail
else ('WARN' if avail_pct | float <= th.mem_available_pct_warn else 'PASS')) }}
mem_detail: >-
{{ '/proc/meminfo unreadable' if avail_pct is not number
else ((m.mem_total_kb | int / 1048576) | round(1) | string ~ ' GiB total, '
~ (m.mem_available_kb | int / 1048576) | round(1) | string ~ ' GiB available ('
~ avail_pct | string ~ '%)') }}
# --- THE ONE MEASUREMENT OF MEMORY THIS MACHINE CANNOT USE ----------------------------
# Measured HERE, once, because TWO checks need the answer: memory.physical_total (directly
# below) and firmware.efi_memattr (further down). Until 2026-09-03 each answered "did this
# machine lose memory?" from DIFFERENT evidence — this one from measurement, the memattr one
# from the presence of a kernel-log line — and they duly contradicted each other on the same
# host. k3s-prod-2 booted with `efi: memattr: Failed to map EFI Memory Attributes table @
# 0xbeeb0018` (the memattr check FAILed, asserting the node "HAS LOST MEMORY") while it was
# measurably using 15.1 GiB of its 16 GB DIMM (this check PASSed at 4.2% unmanaged). The
# measurement was right; the inference from the log line was wrong — the memattr block below
# has the refined mechanism.
#
# THE FIX IS STRUCTURAL, NOT A RE-WORDING. The shortfall is computed in exactly this one
# place and both checks read this fact: neither derives a shortfall of its own, and the
# memattr check may only report memory LOST when `status` here is FAIL. So the two cannot
# disagree about whether memory was lost — there is only one verdict on that question, and
# it is this one. Thresholds and their measured baselines: defaults/main.yml.
- name: Assess — measure memory the machine has and cannot use (once, shared by two checks)
ansible.builtin.set_fact:
node_health_mem_shortfall: "{{ {
'zones_pct': z_pct, 'zones_status': z_status,
'dimm_pct': d_pct, 'dimm_status': d_status, 'installed_kb': inst_kb,
'status': worst_status } }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
z: "{{ node_health_facts.memory.zones }}"
dev: "{{ node_health_facts.memory.devices }}"
m: "{{ node_health_facts.memory }}"
# 1. /proc/zoneinfo: pages the kernel says are physically PRESENT, minus the pages it
# actually handed to the allocator (sum(managed) IS MemTotal). World-readable, no
# tools, identical on metal and in a VM — so this half is available almost always.
z_pct: "{{ z.unmanaged_pct if (z.available | bool) else none }}"
z_status: >-
{{ 'SKIP' if z_pct is not number
else ('FAIL' if z_pct | float >= th.mem_unmanaged_pct_fail
else ('WARN' if z_pct | float >= th.mem_unmanaged_pct_warn else 'PASS')) }}
# 2. SMBIOS type 17 vs MemTotal. Root-only and bare-metal-only, so it SKIPS on the dev
# VMs and wherever escalation is unavailable — with dmidecode's own reason attached,
# never as a zero DIMM count. Independent of the kernel's memory map entirely.
inst_kb: "{{ dev.installed_kb if (dev.available | bool) else none }}"
d_pct: >-
{{ (((inst_kb | int) - (m.mem_total_kb | int)) * 100.0 / (inst_kb | int)) | round(1)
if (inst_kb is number and m.mem_total_kb is number and (inst_kb | int) > 0)
else none }}
d_status: >-
{{ 'SKIP' if d_pct is not number
else ('FAIL' if d_pct | float >= th.mem_dimm_shortfall_pct_fail
else ('WARN' if d_pct | float >= th.mem_dimm_shortfall_pct_warn else 'PASS')) }}
# Worst sub-verdict wins; SKIP only when NEITHER measurement was obtained — two
# SKIPs must never add up to a PASS.
worst_status: >-
{{ (['PASS', 'WARN', 'FAIL'] | select('in', [z_status, d_status]) | list | last)
| default('SKIP', true) }}
# THE CAUSE-INDEPENDENT MEMORY-LOSS CHECK: it reports the measurement above. Its sibling
# firmware.efi_memattr names the one cause we have actually hit here; this one asks the
# blunter question — "is this machine using the RAM it has?" — and answers it from two
# sources that share NOTHING with the kernel log, so a future firmware, kernel or hypervisor
# losing memory for some other reason still shows up.
- name: Assess — usable memory vs the memory this machine physically has
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'memory.physical_total',
'title': 'Usable memory vs installed/present memory (independent of the kernel log)',
'status': phys_status,
'detail': phys_detail }] }}"
vars:
ansible_become: false
ms: "{{ node_health_mem_shortfall }}"
z: "{{ node_health_facts.memory.zones }}"
dev: "{{ node_health_facts.memory.devices }}"
m: "{{ node_health_facts.memory }}"
# Straight from the shared measurement — this check re-derives nothing.
z_pct: "{{ ms.zones_pct }}"
inst_kb: "{{ ms.installed_kb }}"
d_pct: "{{ ms.dimm_pct }}"
phys_status: "{{ ms.status }}"
# What the kernel log says, IF it was read — quoted here only as attribution for the
# measured shortfall. The verdict above is the shared measurement and never depends on
# the log; firmware.efi_memattr reads that same verdict, so the two always agree.
phys_cause: >-
{{ ('. CAUSE: the kernel logged the EFI Memory Attributes table failure — see check
firmware.efi_memattr and infra/PROD-PROVISIONING.md §0b')
if (node_health_facts.klog.memattr_state | default('')) == 'failed_to_map'
else ('. The kernel log was NOT read here, so this shortfall is unattributed —
see check firmware.efi_memattr')
if not (node_health_facts.klog.available | bool)
else '' }}
phys_detail: >-
{{ (('present ' ~ ((z.present_kb | int) / 1048576) | round(1) | string ~ ' GiB, managed '
~ ((z.managed_kb | int) / 1048576) | round(1) | string ~ ' GiB — '
~ z_pct | string ~ '% of the RAM the kernel memory map reports as physically PRESENT
was never handed to the allocator (/proc/zoneinfo)')
if z_pct is number
else ('present-vs-managed pages NOT read — ' ~ (z.reason | default('no reason recorded', true))))
~ '. '
~ (('SMBIOS reports ' ~ ((inst_kb | int) / 1048576) | round(1) | string ~ ' GiB installed across '
~ dev.populated_slots | string ~ ' of ' ~ dev.slots | string ~ ' slot(s) ('
~ (dev.devices | selectattr('size_kb', 'number') | map(attribute='locator') | join(', '))
~ '); MemTotal is ' ~ ((m.mem_total_kb | int) / 1048576) | round(1) | string
~ ' GiB — a ' ~ d_pct | string ~ '% shortfall')
if d_pct is number
else ('installed DIMM capacity NOT read — ' ~ (dev.reason | default('no reason recorded', true))))
~ (('. A few percent is normal and expected — the kernel image, memmap, firmware
reservations and (on this platform) 64 MiB of graphics stolen memory.
A shortfall of this size is memory the machine HAS and cannot use')
if phys_status in ['WARN', 'FAIL'] else '')
~ (phys_cause if phys_status in ['WARN', 'FAIL'] else '') }}
# WHY THIS CHECK IS NOT JUST `SwapTotal == 0`. It used to be, and it read the answer
# wrong in both directions. It called active swap "this node has not been baselined yet",
# which was a MIS-DIAGNOSIS — measured 2026-09-07, all three prod nodes had been
# baselined, had their fstab swap line commented out by node_baseline, and were STILL
# swapping 977 MiB each after a reboot. Whoever read that WARN would have re-run the very
# role that had already run, and watched swap come back at the next boot again.
#
# The real mechanism is systemd-gpt-auto-generator: it claims any GPT partition typed
# 0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap") from the PARTITION TABLE, builds a
# .swap unit into /run/systemd/generator.late/ and links it into swap.target.wants/. It
# never reads /etc/fstab, so `swapoff -a` + a commented fstab line only holds until the
# next boot. roles/node_baseline now masks swap.target (the single unit those generated
# units are `WantedBy`) plus the generated units themselves.
#
# The other direction matters just as much: SwapTotal == 0 is NOT proof that swap is
# disabled — a node freshly `swapoff`-ed reads zero and swaps again on reboot. So this
# check now asks both questions, and WARNs on a node that is clean right now but has an
# unmasked generated swap unit waiting for the next boot.
- name: Assess — swap (kubelet requires it off, and off ACROSS REBOOTS)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'memory.swap',
'title': 'Swap disabled, and disabled across reboots (kubelet prerequisite)',
'status': swap_status,
'detail': swap_detail }] }}"
vars:
ansible_become: false
m: "{{ node_health_facts.memory }}"
p: "{{ node_health_facts.memory.swap_persistence | default({}, true) }}"
# Durability was actually measurable on this node (the collector could read
# /etc/systemd/system and the generator dirs). When false, say so rather than
# implying a clean bill from a failed read.
swap_durable_known: "{{ (p.readable | default(false)) | bool }}"
# Units a reboot would bring back: produced by a generator, not masked, and with
# swap.target itself unmasked so something can still pull them in.
swap_will_return: >-
{{ (swap_durable_known | bool)
and not (p.swap_target_masked | default(false) | bool)
and (p.unmasked_generated_units | default([], true) | length) > 0 }}
swap_units_returning: "{{ p.unmasked_generated_units | default([], true) | join(', ') }}"
swap_active_gib: "{{ (m.swap_total_kb | int / 1048576) | round(2) | string }}"
swap_status: >-
{{ 'SKIP' if m.swap_total_kb is not number
else ('WARN' if (m.swap_total_kb | int) > 0
else ('WARN' if (swap_will_return | bool) else 'PASS')) }}
swap_cause: >-
systemd-gpt-auto-generator re-creates a .swap unit for the GPT partition typed
0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap") at EVERY boot, straight from
the partition table and without reading /etc/fstab. This is NOT "the node has not
been baselined" — `swapoff -a` and a commented fstab line simply do not survive a
reboot. The fix is roles/node_baseline masking swap.target and the generated units:
re-run `node-baseline.yml --tags system` against this node.
swap_detail: >-
{{ 'swap state unreadable' if m.swap_total_kb is not number
else ((swap_active_gib ~ ' GiB swap active'
~ (' (' ~ swap_units_returning ~ ')' if swap_units_returning else '')
~ ' — ' ~ swap_cause)
if (m.swap_total_kb | int) > 0
else ('no swap active, but it would come back at the next boot via '
~ swap_units_returning ~ ' — ' ~ swap_cause
if (swap_will_return | bool)
else ('no swap active'
~ (' and none can be activated: swap.target is masked'
if (p.swap_target_masked | default(false) | bool)
else (' and no swap unit exists on this node'
if (p.generated_units | default([], true) | length) == 0
else ' and every generated swap unit is masked'))
if (swap_durable_known | bool)
else 'no swap active (durability NOT verified — the collector
could not read /etc/systemd/system or /run/systemd)'))) }}
- name: Assess — ECC / EDAC correctable errors
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'memory.ecc',
'title': 'ECC memory errors (EDAC correctable/uncorrectable counters)',
'status': ecc_status,
'detail': ecc_detail }] }}"
vars:
ansible_become: false
e: "{{ node_health_facts.memory.edac }}"
ecc_status: >-
{{ 'SKIP' if not (e.controllers_present | bool)
else ('FAIL' if (e.ue_total | int) > 0
else ('WARN' if (e.ce_total | int) > 0 else 'PASS')) }}
ecc_detail: >-
{{ ('no EDAC memory controller is bound (' ~ ('/sys/devices/system/edac/mc exists but is empty'
if e.sysfs_present | bool else 'no EDAC sysfs at all')
~ ') — this platform does not report ECC status. Non-ECC consumer RAM or a VM;
"no errors" CANNOT be confirmed on this hardware.')
if not (e.controllers_present | bool)
else ((e.controllers | length) | string ~ ' controller(s), correctable='
~ e.ce_total | string ~ ', uncorrectable=' ~ e.ue_total | string) }}
# --- CPU ----------------------------------------------------------------------------
- name: Assess — CPU thermal throttling
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'cpu.throttle',
'title': 'CPU thermal throttling since boot',
'status': thr_status,
'detail': thr_detail }] }}"
vars:
ansible_become: false
t: "{{ node_health_facts.cpu.throttle }}"
thr_status: >-
{{ 'SKIP' if t is none
else ('PASS' if ((t.core_throttle_count_total | int) + (t.package_throttle_count | int)) == 0
else 'WARN') }}
thr_detail: >-
{{ 'no thermal_throttle counters (VM guest or unsupported CPU)' if t is none
else ('core throttle events=' ~ t.core_throttle_count_total | string
~ ' (' ~ t.core_throttle_total_time_ms | string ~ ' ms), package events='
~ t.package_throttle_count | string) }}
- name: Assess — CPU inventory and frequency scaling
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'cpu.inventory',
'title': 'CPU model, topology and frequency scaling',
'status': 'INFO',
'detail': cpu_detail }] }}"
vars:
ansible_become: false
c: "{{ node_health_facts.cpu }}"
cpu_detail: >-
{{ (c.model | default('unknown')) ~ ' — ' ~ (c.cpus | default('?')) ~ ' logical CPU(s), '
~ (c.sockets | default('?')) ~ ' socket(s) x ' ~ (c.cores_per_socket | default('?'))
~ ' core(s) x ' ~ (c.threads_per_core | default('?')) ~ ' thread(s); load '
~ (c.loadavg | default([]) | join(' ')) ~ '; '
~ ('governor=' ~ c.scaling.governor ~ ' driver=' ~ c.scaling.driver
~ ' cur=' ~ ((c.scaling.cur_khz | int) / 1000) | round | int | string ~ 'MHz'
~ ' max=' ~ ((c.scaling.max_khz | int) / 1000) | round | int | string ~ 'MHz'
if c.scaling is mapping else 'no cpufreq scaling exposed') }}
- name: Assess — hardware virtualisation (KubeVirt prerequisite, R-PLAT-03)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'cpu.virtualisation',
'title': 'Hardware virtualisation available (VT-x/AMD-V + /dev/kvm)',
'status': virt_status,
'detail': virt_detail }] }}"
vars:
ansible_become: false
c: "{{ node_health_facts.cpu }}"
virt_status: >-
{{ 'PASS' if (c.hardware_virt_flag | bool and c.kvm_device | bool) else 'WARN' }}
virt_detail: >-
{{ 'vmx/svm flag=' ~ c.hardware_virt_flag | string ~ ', /dev/kvm='
~ c.kvm_device | string
~ (', lscpu Virtualization=' ~ c.virtualization if c.virtualization else '')
~ (', running as a guest of ' ~ c.hypervisor_vendor if c.is_virtual_guest | bool else '') }}
# --- Thermals -----------------------------------------------------------------------
- name: Assess — temperatures
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'thermal.max',
'title': 'Hottest reported sensor',
'status': temp_status,
'detail': temp_detail }] }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
hottest: "{{ (node_health_temps_c | max) if (node_health_temps_c | length) > 0 else none }}"
temp_status: >-
{{ 'SKIP' if hottest is not number
else ('FAIL' if hottest | float >= th.temp_fail_c
else ('WARN' if hottest | float >= th.temp_warn_c else 'PASS')) }}
temp_detail: >-
{{ 'no thermal sensor exposed (typical in a VM)' if hottest is not number
else (hottest | string ~ 'C hottest of ' ~ (node_health_temps_c | length) | string
~ ' sensors; zones: '
~ (node_health_facts.thermal.zones
| map(attribute='type') | join(', ')) ) }}
# --- Pressure-stall (PSI) -----------------------------------------------------------
- name: Assess — pressure stall (CPU, memory, I/O)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'pressure.' ~ item,
'title': 'PSI ' ~ item ~ ' — some avg60 (% of wall time stalled)',
'status': psi_status,
'detail': psi_detail }] }}"
loop: [cpu, memory, io]
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
psi: "{{ node_health_facts.pressure[item] | default(none) }}"
avg60: "{{ psi.some.avg60 | default(none) if psi is mapping else none }}"
psi_status: >-
{{ 'SKIP' if avg60 is not number
else ('FAIL' if avg60 | float >= th.psi_some_avg60_fail
else ('WARN' if avg60 | float >= th.psi_some_avg60_warn else 'PASS')) }}
psi_detail: >-
{{ '/proc/pressure/' ~ item ~ ' unavailable (kernel without CONFIG_PSI, or psi=0)'
if avg60 is not number
else ('some avg10=' ~ psi.some.avg10 | string ~ ' avg60=' ~ psi.some.avg60 | string
~ ' avg300=' ~ psi.some.avg300 | string
~ (' | full avg60=' ~ psi.full.avg60 | string if psi.full is defined else '')) }}
# --- Network ------------------------------------------------------------------------
- name: Assess — link state and speed
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'network.link',
'title': 'Physical NIC link state and negotiated speed',
'status': link_status,
'detail': link_detail }] }}"
vars:
ansible_become: false
th: "{{ node_health_thresholds }}"
up: "{{ node_health_nics_up }}"
slow: >-
{{ up | selectattr('speed_mbps', 'number')
| selectattr('speed_mbps', 'lt', th.nic_min_speed_mbps | int) | list }}
link_status: >-
{{ 'FAIL' if (up | length) == 0
else ('WARN' if (slow | length) > 0 else 'PASS') }}
link_detail: >-
{{ 'no physical interface is up' if (up | length) == 0
else (up | map(attribute='name') | zip(up | map(attribute='speed_mbps'))
| map('join', '=') | join('Mb/s, ') ~ 'Mb/s'
~ ' (driver ' ~ (up | map(attribute='driver') | join(', ')) ~ ')') }}
- name: Assess — link error counters
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'network.errors',
'title': 'NIC error counters since boot (rx/tx errors, CRC, carrier)',
'status': ('PASS' if err_total | int == 0 else 'WARN'),
'detail': err_detail }] }}"
vars:
ansible_become: false
up: "{{ node_health_nics_up }}"
err_keys: [rx_errors, tx_errors, rx_crc_errors, rx_frame_errors, rx_over_errors, tx_carrier_errors]
err_total: >-
{{ up | map(attribute='statistics') | map('dict2items') | flatten
| selectattr('key', 'in', err_keys) | map(attribute='value')
| select('number') | list | sum }}
drop_total: >-
{{ up | map(attribute='statistics') | map('dict2items') | flatten
| selectattr('key', 'in', ['rx_dropped', 'tx_dropped']) | map(attribute='value')
| select('number') | list | sum }}
err_detail: >-
{{ err_total | string ~ ' error(s), ' ~ drop_total | string ~ ' drop(s) across '
~ (up | map(attribute='name') | join(', ')) }}
# The collector reports the node's REAL upstreams: where /etc/resolv.conf holds only
# systemd-resolved's 127.0.0.53 stub (dev's shape) it asks resolved for the servers behind
# it, because counting the stub would be a permanent FALSE single-upstream WARN that no
# correct configuration could ever clear. When that lookup fails it reports null WITH a
# reason rather than a number — so the check SKIPs. A skip is honest; a WARN derived from
# an unknown is a fabricated verdict, and a PASS would be worse.
- name: Assess — DNS resolver redundancy
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'network.resolvers',
'title': 'Configured DNS upstreams (single upstream is a known SPOF here)',
'status': ('SKIP' if node_health_facts.network.nameservers is none
else ('PASS' if (ns4 | length) >= (node_health_thresholds.min_nameservers | int)
else 'WARN')),
'detail': ns_detail }] }}"
vars:
ansible_become: false
# `| default([], true)` only ever fires on the null (unreadable) path, where the
# counts below are unused because the status is already SKIP.
ns: "{{ node_health_facts.network.nameservers | default([], true) }}"
# Counted PER FAMILY on purpose. The outage this check exists for was a single IPv4
# upstream; IPv6 upstreams do not cover for it unless the node actually has working
# IPv6 egress, which this role cannot prove from sysfs alone. So the verdict keys off
# the IPv4 count and the detail shows the full picture.
ns4: "{{ ns | select('search', '^[0-9.]+$') | list }}"
ns6: "{{ ns | reject('search', '^[0-9.]+$') | list }}"
# Where the numbers came from — resolv.conf directly, or resolved behind the stub.
ns_src: "{{ node_health_facts.network.resolver_source | default('', true) }}"
ns_detail: >-
{{ ('not read — ' ~ (node_health_facts.network.resolver_unavailable_reason
| default('the collector recorded no reason', true))
~ '. The upstream count is UNKNOWN here — not zero, and not one.')
if node_health_facts.network.nameservers is none
else ((ns | length) | string ~ ' nameserver(s) — IPv4: '
~ (ns4 | join(', ') | default('none', true))
~ '; IPv6: ' ~ (ns6 | join(', ') | default('none', true))
~ (' (read via ' ~ ns_src ~ ')' if ns_src else '')
~ ('. Only ' ~ (ns4 | length) | string ~ ' IPv4 upstream: a single IPv4 upstream
already broke containerd image pulls and CoreDNS forwarding on dev. The fix
is the `dns`-tagged block in roles/node_baseline — set this environment
node_baseline_dns_servers (role defaults for dev, group_vars/k3s_prod for
prod) and re-run node-baseline.yml --tags dns. IPv6 upstreams only compensate
if the node has working IPv6 egress, which this check cannot verify.'
if (ns4 | length) < (node_health_thresholds.min_nameservers | int) else '')) }}
# --- OS / firmware ------------------------------------------------------------------
- name: Assess — pending reboot
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'os.reboot_required',
'title': 'Pending reboot flag',
'status': ('WARN' if node_health_facts.os.reboot_required | bool else 'PASS'),
'detail': ('/run/reboot-required present — a package upgrade needs a restart'
if node_health_facts.os.reboot_required | bool
else 'no reboot flagged') }] }}"
vars:
ansible_become: false
- name: Assess — OS, kernel and uptime
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'os.inventory',
'title': 'OS, kernel and uptime',
'status': 'INFO',
'detail': os_detail }] }}"
vars:
ansible_become: false
o: "{{ node_health_facts.os }}"
os_detail: >-
{{ (o.pretty_name | default('unknown OS')) ~ ', kernel ' ~ (o.kernel | default('?'))
~ ', up ' ~ ((o.uptime_seconds | float / 86400) | round(2) | string) ~ ' day(s)' }}
- name: Assess — firmware / DMI inventory
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'firmware.inventory',
'title': 'Chassis, board and BIOS firmware',
'status': ('INFO' if node_health_facts.dmi.present | bool else 'SKIP'),
'detail': dmi_detail }] }}"
vars:
ansible_become: false
d: "{{ node_health_facts.dmi }}"
dmi_detail: >-
{{ 'no DMI table exposed (typical for some VMs/containers)' if not (d.present | bool)
else ((d.sys_vendor | default('?')) ~ ' ' ~ (d.product_name | default('?'))
~ ' / board ' ~ (d.board_name | default('?'))
~ ' / BIOS ' ~ (d.bios_vendor | default('?')) ~ ' '
~ (d.bios_version | default('?')) ~ ' (' ~ (d.bios_date | default('?')) ~ ')'
~ (' — serial numbers not read (root-only DMI fields)'
if d.product_serial is none else '')) }}
# --- EFI Memory Attributes Table ------------------------------------------------------
# THE AUTHORITATIVE WRITE-UP IS infra/PROD-PROVISIONING.md §0b. Do not re-derive it here;
# what follows is only what this check needs to be read correctly.
#
# HP 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
# are involved, and conflating them is what made this check wrong until 2026-09-03:
#
# efi_memattr_init() tbl_size = sizeof(*tbl) + num_entries * desc_size, then
# memblock_reserve(efi_mem_attr_table, tbl_size). tbl_size
# is a signed int. CATASTROPHIC LOSS REQUIRES IT TO BE
# NEGATIVE: a garbage product with bit 31 set sign-extends
# through phys_addr_t and memblock_cap_size() clamps it to
# PHYS_ADDR_MAX — everything above the table is reserved.
# A garbage-but-POSITIVE tbl_size reserves a modest, wrong
# amount and costs nothing measurable.
# efi_memattr_apply_permissions() memremap(efi_mem_attr_table, tbl_size, ...) later, and
# THIS is what prints "Failed to map". It fails for ANY
# sufficiently wrong tbl_size — negative OR merely large.
#
# SO: "Failed to map" PROVES the table was corrupt and the kernel trusted it. It does NOT
# prove memory was lost. The outcome depends on the VALUE of the garbage at that address on
# that boot, not on the address. k3s-prod-2 lost 13.3 GiB twice at 0xbeeb0018 and then, on
# 2026-09-03, booted with all 15.1 GiB at the SAME address and the SAME log line. That boot
# is the counter-example that falsified the old inference.
#
# THE VERDICT IS THEREFORE BASED ON MEASURED MEMORY, NOT ON THE LOG LINE:
#
# "Failed to map ... @ 0x..." the bug FIRED: corrupt table, trusted by the kernel.
# FAIL when the shared measurement (node_health_mem_shortfall) says memory is
# catastrophically missing — memory is lost NOW.
# WARN otherwise — this boot drew a survivable garbage value; the next may not.
# "Unexpected ... version <N>" version read > 2, so the kernel bailed out before
# reserving anything. WARN on a kernel < 6.13 (exposed, and this boot merely took the
# safe path) but PASS on >= 6.13, where that bail-out is the FIRST of three validation
# gates and no draw can reach the fatal arithmetic any more. Same line, opposite
# meaning; the kernel version is what separates them. See memattr_kernel_fixed below.
# FW_BUG "Corrupted EFI Memory Attributes Table detected!" the >= 6.13 THIRD gate
# (num_entries > 64K) firing. PASS. It is NOT the only proof the fix is active and
# must not be treated as such: it is only reachable on a boot whose garbage leaves the
# version field <= 2, so a fixed kernel very often prints the "Unexpected ... version"
# line instead. All three prod nodes did exactly that on 2026-09-04.
#
# The two lines are not "lucky vs unlucky" any more — there are three outcomes (bail-out,
# a trusted table that cost nothing, a trusted table that cost gigabytes) and only the
# measurement separates the last two. ON A KERNEL < 6.13, never PASS on either line: the
# firmware is publishing a corrupt table and the draw is repeated on every boot. On >= 6.13
# the draw still happens but every outcome is a rejection, so the same lines mean the
# validation is working.
#
# WHY THIS IS A CHECK AND NOT A NOTE IN A RUNBOOK: it is boot-to-boot NON-DETERMINISTIC,
# so it cannot be diagnosed once. It has to be measured on every health run.
#
# IT CANNOT CONTRADICT memory.physical_total. Both checks read one fact,
# node_health_mem_shortfall, computed once further up this file; this check does not measure
# memory at all. Its FAIL is gated on that fact being FAIL, so "memory was lost" has exactly
# one verdict on any host and both checks quote it.
#
# THE HONESTY TRAP, AND HOW IT IS AVOIDED. "No memattr line" means the node is unaffected
# ONLY if the kernel log was actually read AND still reaches back to early boot — every
# memattr diagnostic is printed in the first fraction of a second, and the ring buffer
# wraps. So this check SKIPs, with the reason, when dmesg could not be read at all, and
# SKIPs again when it was read but carries no boot anchor. Neither is allowed to become a
# PASS. memory.physical_total measures the SYMPTOM from /proc/zoneinfo and needs no kernel
# log at all, so a node whose log is unreadable is still not unmeasured.
- name: Assess — EFI Memory Attributes table (firmware bug + unvalidated kernel parse)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'firmware.efi_memattr',
'title': 'EFI Memory Attributes table — corrupt table reserved as RAM by the kernel',
'status': memattr_status,
'detail': memattr_detail }] }}"
vars:
ansible_become: false
k: "{{ node_health_facts.klog }}"
z: "{{ node_health_facts.memory.zones }}"
# THE ONE MEMORY VERDICT, read (never re-derived) from the shared measurement computed
# near memory.physical_total. This check contributes the kernel-log facts only.
ms: "{{ node_health_mem_shortfall }}"
ma_state: "{{ k.memattr_state | default(none) }}"
# DOES THE RUNNING KERNEL VALIDATE THE TABLE? Everything below turns on this. >= 6.13
# carries 8fbe4c49c0cc (hardened by 5d0faa8e8369), which gates efi_memattr_init() behind
# THREE checks before it computes tbl_size at all:
# tbl->version > 2 -> "Unexpected ... table version %d"
# desc_size implausible -> "Unexpected ... descriptor size %u ..."
# num_entries > SZ_64K -> FW_BUG "Corrupted EFI Memory Attributes
# Table detected! ..."
# ALL THREE ARE REJECTIONS, and only the LAST one prints the FW_BUG line. Which message a
# given boot produces is decided by WHICH field that boot's garbage lands in — it is not
# a choice between "fixed" and "not fixed". On < 6.13 only the first check exists, so a
# version that reads <= 2 goes straight to the unvalidated multiply and the catastrophic
# memblock_reserve(). That is the whole difference, and it is why the verdict for
# `bad_version` below is opposite on the two kernels: same log line, completely different
# meaning. Major.minor is extracted first — the raw string ("7.1.8+deb13-amd64") is not
# something the `version` test can parse.
memattr_kernel_mm: "{{ (node_health_facts.os.kernel | default('', true)) | regex_search('^[0-9]+\\.[0-9]+') | default('', true) }}"
memattr_kernel_fixed: "{{ (memattr_kernel_mm | length) > 0 and (memattr_kernel_mm is version('6.13', '>=')) }}"
# `Failed to map` means the bug FIRED — always at least a WARN, never a PASS. Whether it
# COST anything is not in the log: only ms.status can escalate this to FAIL, which is why
# this check and memory.physical_total can no longer disagree about lost memory.
# `bad_version` is the one verdict that depends on the kernel: on < 6.13 it is a lucky
# boot (WARN — the next draw may be the fatal one); on >= 6.13 it is the validation
# WORKING (PASS — the fatal draw no longer exists). Reporting a fixed node as WARN
# forever would make the check unable to ever show the fix landing, which is exactly what
# it is for.
memattr_status: >-
{{ 'SKIP' if not (k.available | bool)
else ('FAIL' if (ma_state == 'failed_to_map' and ms.status == 'FAIL')
else ('WARN' if ma_state == 'failed_to_map'
else ('PASS' if ma_state == 'corrupt_rejected'
else (('PASS' if (memattr_kernel_fixed | bool) else 'WARN')
if ma_state == 'bad_version'
else ('PASS' if (k.boot_covered | bool) else 'SKIP'))))) }}
# One remediation, quoted by both findings. NOTE what it must NOT say: there is no
# kernel cmdline workaround, because efi_memattr_init() runs before any efi= bail-out
# could take effect. Suggesting one would be a fix that quietly does nothing.
memattr_fix: >-
FIX: boot a kernel >= 6.13 (Debian 13: linux-image-amd64 from trixie-backports) —
upstream commit 8fbe4c49c0cc "efi/memattr: Ignore table if the size is clearly
bogus", first released in v6.13 and absent from v6.12 and from the current
linux-6.12.y stable branch. This node runs {{ node_health_facts.os.kernel | default('an unknown kernel') }}.
There is NO kernel cmdline workaround: efi_memattr_init() runs before any efi=
bail-out could take effect. Rebooting, clearing CMOS, changing a BIOS setting or
unplugging USB only change where the table lands and may APPEAR to fix it — they are
a lottery ticket, not a fix. Full write-up: infra/PROD-PROVISIONING.md §0b.
# The measurement, quoted from the shared fact — the ONLY thing this finding is allowed
# to say about memory being lost or intact. Figures come from /proc/zoneinfo, which the
# kernel log has no part in; the verdict word comes from ms.status, so it always agrees
# with memory.physical_total. When the measurement is missing it says so, and says why.
memattr_figures: >-
{{ ('MemTotal ' ~ ((z.managed_kb | int) / 1048576) | round(1) | string ~ ' GiB of '
~ ((z.present_kb | int) / 1048576) | round(1) | string ~ ' GiB present, i.e. '
~ ms.zones_pct | string ~ '% of the RAM this machine has was never handed to the
allocator (/proc/zoneinfo)')
if ms.zones_pct is number
else (('MemTotal is ' ~ ms.dimm_pct | string ~ '% short of the DIMM capacity SMBIOS
reports as installed (/proc/zoneinfo NOT read — '
~ (z.reason | default('no reason recorded', true)) ~ ')')
if ms.dimm_pct is number else '') }}
memattr_corroboration: >-
{{ (' MEASURED THIS BOOT, independently of this log (check memory.physical_total, verdict '
~ ms.status ~ '): ' ~ memattr_figures
~ (' — a catastrophic shortfall: memory IS lost on this boot.' if ms.status == 'FAIL'
else (' — an elevated shortfall, above the warn threshold.' if ms.status == 'WARN'
else ' — the normal few percent (kernel image, memmap, firmware
reservations, graphics stolen memory). NO measurable memory was lost
on this boot.')))
if (memattr_figures | trim | length) > 0
else (' Whether memory was lost on this boot could NOT be measured here: neither
present-vs-managed pages nor installed DIMM capacity was read — see check
memory.physical_total for the reasons.') }}
# The kernel log lines themselves, verbatim. A verdict the operator cannot check
# against the evidence is not much better than a wrong one.
memattr_evidence: >-
{{ ((' Kernel log: ' ~ (k.memattr_lines | join(' | ')))
if (k.memattr_lines | default([]) | length) > 0 else '')
~ ((' At boot the kernel reported '
~ ((k.memory_line.available_kb | int) / 1048576) | round(1) | string ~ ' GiB of '
~ ((k.memory_line.total_kb | int) / 1048576) | round(1) | string ~ ' GiB available, '
~ ((k.memory_line.reserved_kb | int) / 1048576) | round(1) | string ~ ' GiB reserved'
~ ' — the per-boot figure PROD-PROVISIONING.md §0b tabulates.')
if (k.memory_line is mapping and (k.memory_line.reserved_kb | default(none)) is number)
else '') }}
memattr_detail: >-
{%- if not (k.available | bool) -%}
the kernel log was NOT read — {{ k.reason | default('no reason recorded', true) }}.
Whether this node is affected is therefore UNKNOWN, not unaffected: the diagnostic
lives only in dmesg.{{ memattr_corroboration }}
{%- elif ma_state == 'failed_to_map' and ms.status == 'FAIL' -%}
THE BUG FIRED AND MEMORY IS LOST ON THIS BOOT — measured, not inferred from this log
line. The kernel failed to map the EFI Memory Attributes table at
{{ k.memattr_table_address | default('an unreported address') }}, which proves the
table was corrupt AND that this kernel trusted it far enough to memblock_reserve()
from its contents; the measurement below shows that reservation was the catastrophic
kind — a NEGATIVE tbl_size (num_entries x desc_size with bit 31 set), sign-extended
through phys_addr_t and clamped to PHYS_ADDR_MAX, reserving everything above the
table.{{ memattr_evidence }}{{ memattr_corroboration }} {{ memattr_fix }}
{%- elif ma_state == 'failed_to_map' -%}
THE BUG FIRED ON THIS BOOT AND THIS BOOT SURVIVED IT — not a pass, and not memory
loss either. The kernel failed to map the EFI Memory Attributes table at
{{ k.memattr_table_address | default('an unreported address') }}: the table was
corrupt and this kernel trusted it far enough to memblock_reserve() from its contents.
WHAT THAT COST is decided by the garbage VALUE at that address on this boot, not by
the address: catastrophic loss requires a NEGATIVE tbl_size (num_entries x desc_size
with bit 31 set) which sign-extends and clamps to PHYS_ADDR_MAX, whereas a garbage
tbl_size that stays POSITIVE reserves a modest, wrong amount that costs nothing
measurable and still fails the later memremap() that printed this line. This boot drew a survivable
value; the draw is repeated on EVERY boot, at the same address — this platform has
been seen to lose 13.3 GiB and to keep its full memory at one identical table address
(infra/PROD-PROVISIONING.md §0b).{{ memattr_evidence }}{{ memattr_corroboration }}
{{ memattr_fix }}
{%- elif ma_state == 'bad_version' and (memattr_kernel_fixed | bool) -%}
the firmware publishes a CORRUPT EFI Memory Attributes table here, and the running
kernel ({{ node_health_facts.os.kernel | default('unknown') }}) is >= 6.13, so it
VALIDATES that table before reserving anything from it (upstream 8fbe4c49c0cc). On this
boot the garbage version field read {{ k.memattr_version | default('> 2') }}, which is
outside the 0..2 the spec allows, so the kernel rejected the table at the FIRST of
efi_memattr_init()'s three gates and never reached the size arithmetic at all. THAT IS
WHY THIS LOG SAYS "Unexpected ... table version" RATHER THAN THE FW_BUG "Corrupted EFI
Memory Attributes Table detected!" LINE: the FW_BUG message comes from the THIRD gate
(num_entries > 64K) and is only reachable on a boot whose garbage happens to leave the
version field <= 2. Both messages are rejections, and which one a boot prints is the
lottery — but on this kernel EVERY ticket in it is now a rejection, so the catastrophic
path (memblock_reserve() of a negative tbl_size, sign-extended and clamped to
PHYS_ADDR_MAX) is closed regardless of the draw. No action.{{ memattr_evidence }}
{{ memattr_corroboration }}
{%- elif ma_state == 'bad_version' -%}
EXPOSED, not immune, though this boot took the safe path. The firmware publishes a
CORRUPT EFI Memory Attributes table here too; this boot survived because its garbage
version field read {{ k.memattr_version | default('> 2') }}, which is > 2, so the
kernel bailed out BEFORE reserving anything from it. The outcome is re-decided on
EVERY boot: if that field reads <= 2 the kernel trusts the table and reserves whatever
tbl_size the garbage produces — nothing measurable if that value is positive,
everything above the table if it is negative.{{ memattr_evidence }}
{{ memattr_corroboration }} {{ memattr_fix }}
{%- elif ma_state == 'corrupt_rejected' -%}
the firmware publishes a corrupt EFI Memory Attributes table AND the running kernel
validated and rejected it (the >= 6.13 behaviour, commit 8fbe4c49c0cc). This bug
cannot cost this node memory on this or any future boot; no
action.{{ memattr_evidence }}{{ memattr_corroboration }}
{%- elif not (k.boot_covered | bool) -%}
NOT DETERMINED. The kernel log carries no memattr line, but it also no longer reaches
back to early boot (no "Linux version" / "BIOS-provided physical RAM map" anchor in
the ring buffer, which wraps). Every memattr diagnostic is printed within the first
fraction of a second, so its absence here proves NOTHING — this is an UNKNOWN, not a
pass. Read /var/log/kern.log for this boot, or re-run after a
reboot.{{ memattr_corroboration }}
{%- elif k.memattr_published is sameas true -%}
the firmware publishes an EFI Memory Attributes table at
{{ k.memattr_table_address | default('an unreported address') }} and the kernel
accepted it without complaint, in a log that still reaches back to early boot. Not
affected on this boot.
{%- elif k.memattr_published is sameas false -%}
this firmware publishes NO EFI Memory Attributes table (the UEFI config-table line
lists no MEMATTR=), so the bug cannot arise on this node. Normal for a Proxmox/OVMF
guest.
{%- else -%}
no UEFI config-table line in the kernel log at all — this node did not boot via UEFI,
so there is no EFI Memory Attributes table to be corrupt. Log reaches back to early
boot, so that absence is measured, not assumed.
{%- endif -%}