Files
home-network-k3s-impl/infra/ansible/roles/node_health/tasks/assess.yml
T
jakman85andClaude Opus 5 a78f010368
gate / gate (pull_request) Successful in 1m40s
gate / conformance (pull_request) Has been skipped
feat(node-health): probe the Intel render device, read-only
The prod nodes already have a working Intel render device — measured by hand on
all three on 2026-09-30. Nothing here installs, loads or changes anything; the
point is that the measurement now re-runs on every health run instead of decaying
silently in a handoff note.

The failure being hunted is silent by construction: Plex hardware-transcodes
through /dev/dri/renderD128, and if that node stops existing (node rebuild, a BIOS
change that disables the onboard IGD, a kernel that binds a different DRM driver)
Plex falls back to software transcoding on its own and the only symptom is a hot
CPU.

All of it is in the UNPRIVILEGED collector pass: lstat() on /dev/dri/*,
grp.getgrgid() for the owning group name, the /sys/class/drm/card*/device/driver
symlink for the bound driver, /sys/bus/pci/devices/*/class for display-class
devices whether or not a driver is bound, /sys/class/drm/card*/engine/* for the
vcs*/vecs* media engines, /proc/modules and /proc/cmdline. lspci and vainfo are
probed but never depended on — neither is in node_baseline's package floor.

Three checks, in the role's existing PASS/WARN/FAIL/SKIP/INFO shape:

  graphics.render_device  the outcome — a usable character-special renderD* node.
                          FAIL only when a display device IS on the PCI bus and
                          there is still no render node. SKIP when the machine has
                          no display device at all (a VM guest), which is why the
                          PCI scan is separate from the DRM scan: /sys/class/drm/
                          card* exists only once a driver has BOUND.
  graphics.drm_driver     what is bound, against the new
                          node_health_graphics_expected_driver default (i915), so
                          the comparison is data and not task logic. `xe` bound in
                          place of i915 is a WARN, not a FAIL: the device works but
                          the userspace media stack differs.
  graphics.media_stack    INFO only, deliberately. GuC/HuC off (i915.enable_guc=0)
                          is an accepted state that costs low-power H.264 encode
                          quality only, and no host vainfo is not a defect at all
                          because Plex ships its own libva inside its container.

The render node's owning group and GID are REPORTED, never asserted: `render` is a
systemd dynamic allocation rather than a Debian static gid, so a rebuild can
legitimately move it off the 992 measured today, and Plex's container entrypoint
stats the device and joins whichever group owns it. Drift stays visible without
being an error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-30 13:04:43 -05:00

1280 lines
78 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'
~ (' via ' ~ 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 '') }}
# --- Graphics / render device ----------------------------------------------------------
# WHY A HEALTH CHECK AND NOT A NOTE IN A RUNBOOK. Plex transcodes on the iGPU through
# /dev/dri/renderD128. When that node stops existing nothing fails loudly — Plex falls back
# to software transcoding on its own and the only symptom is a hot CPU and stuttering
# clients. A node rebuild, a BIOS setting that disables the onboard IGD, or a kernel that
# binds a different DRM driver all produce exactly that silent degradation. It was measured
# by hand on all three prod nodes on 2026-09-30 and found working; a fact measured once
# decays silently, so it is re-measured on every health run instead.
#
# READ-ONLY, AND UNPRIVILEGED. Every fact behind these three checks comes from the
# unprivileged pass: stat() on /dev/dri, the /sys/class/drm bound-driver symlink,
# /sys/bus/pci/devices class codes, /proc/modules and /proc/cmdline. The role installs
# nothing (no vainfo, no pciutils), loads no module and changes no group — see the role
# README. The ONE fact that is root-gated is i915's enable_guc module parameter (mode 0400),
# and it reports as an honest UNKNOWN with the reason rather than as a 0.
#
# THE THREE VERDICTS ARE SPLIT ON PURPOSE:
# graphics.render_device the OUTCOME — can anything hardware-transcode on this node.
# graphics.drm_driver the CAUSE side — what is bound, and is it what this site expects.
# graphics.media_stack INFO only. GuC/HuC and host VAAPI userspace both belong here and
# NEITHER is ever a failure; see the task's own comment for why.
- name: Assess — locate the display device and its bound driver (once, shared by three checks)
ansible.builtin.set_fact:
node_health_render:
# Display-class PCI devices (base class 0x03), driver bound or not. This is the
# question "is there a GPU in this machine at all", and it is answered from the PCI
# bus rather than from /sys/class/drm because a card* entry only exists once a driver
# has BOUND — so a GPU with nothing bound to it has no card* and would otherwise look
# identical to a machine with no GPU.
display_devices: "{{ g.pci_display }}"
bound_drivers: >-
{{ g.pci_display | map(attribute='driver') | select('string') | unique | list }}
# Only a CHARACTER SPECIAL file is a usable render node. Anything else called renderD*
# is an anomaly, not a device, and must not be counted as one.
render_chardevs: "{{ g.render_nodes | selectattr('char_device') | list }}"
# Video (vcs*) and video-enhancement (vecs*) engines across every bound card — the
# difference between "a GPU is bound" and "this GPU can do media work".
media_engines: >-
{{ g.drm_cards | map(attribute='media_engines') | flatten | unique | sort | list }}
vars:
ansible_become: false
g: "{{ node_health_facts.graphics }}"
- name: Assess — DRM render node for hardware transcoding
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'graphics.render_device',
'title': 'DRM render node present for hardware transcoding (/dev/dri/renderD*)',
'status': render_status,
'detail': render_detail }] }}"
vars:
ansible_become: false
g: "{{ node_health_facts.graphics }}"
r: "{{ node_health_render }}"
# SKIP, not PASS and not FAIL, when the machine has no display device at all: there is
# then nothing to grade, and the same convention thermal.max already uses for "no sensor
# on this hardware" applies. FAIL is reserved for the state that actually breaks Plex —
# a GPU IS on the bus and there is still no render node.
render_status: >-
{{ 'SKIP' if (r.display_devices | length) == 0
else ('PASS' if (r.render_chardevs | length) > 0 else 'FAIL') }}
dri_listing: >-
{{ (g.devices | map(attribute='name') | join(', ')) if (g.devices | length) > 0
else 'nothing' }}
render_detail: >-
{%- if (r.display_devices | length) == 0 -%}
this machine exposes no display-class PCI device (base class 0x03) at all, so there is
no GPU here for a render node to belong to and hardware transcoding is not available on
this node by construction. Normal for a VM guest. Measured from /sys/bus/pci/devices,
not assumed.
{%- elif (r.render_chardevs | length) > 0 -%}
{{ r.render_chardevs | length }} render node(s):
{% for d in r.render_chardevs %}{{ d.path }} (character special, mode {{ d.mode }},
owner {{ d.owner | default(d.uid, true) }}, group {{ d.group | default('unresolved', true) }}
gid {{ d.gid }}){{ ', ' if not loop.last else '' }}{% endfor %}.
/dev/dri holds: {{ dri_listing }}.
THE OWNING GROUP AND GID ARE REPORTED AS OBSERVED, NEVER ASSERTED against a literal:
`render` is a systemd DYNAMIC allocation rather than a Debian static one, so a rebuilt
node may legitimately land on a different number (all three prod nodes read gid 992 on
2026-09-30), and Plex's container entrypoint stats the device and joins whichever group
owns it. Drift is therefore visible in this line without being a failure.
{%- else -%}
HARDWARE TRANSCODING IS BROKEN ON THIS NODE. A display-class device IS present on the
PCI bus ({{ r.display_devices | map(attribute='address') | join(', ') }}) but there is
no usable character-special render node: /dev/dri
{{ 'does not exist' if not (g.dri_present | bool) else 'holds ' ~ dri_listing }}.
{{ ('Note that /dev/dri holds ' ~ (g.render_nodes | map(attribute='name') | join(', '))
~ ' — present, but NOT character special, which is an anomaly in its own right. ')
if (g.render_nodes | length) > 0 else '' }}
Nothing fails loudly in this state — Plex silently falls back to software transcoding
and the only symptom is CPU load. Check graphics.drm_driver below for whether a DRM
driver bound at all, then whether the onboard IGD is still enabled in BIOS.
{%- endif -%}
- name: Assess — DRM driver bound to the display device
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'graphics.drm_driver',
'title': 'DRM driver bound to the display device (expected ' ~ expected ~ ')',
'status': driver_status,
'detail': driver_detail }] }}"
vars:
ansible_become: false
g: "{{ node_health_facts.graphics }}"
r: "{{ node_health_render }}"
expected: "{{ node_health_graphics_expected_driver }}"
# WARN rather than FAIL when a DIFFERENT driver is bound (the `xe` case): the device
# still works, but the userspace media stack is not the same one, so it is a change the
# operator must see before a transcode regression surfaces as a user complaint. FAIL is
# for nothing bound at all, which is the cause of a missing render node.
driver_status: >-
{{ 'SKIP' if (r.display_devices | length) == 0
else ('FAIL' if (r.bound_drivers | length) == 0
else ('PASS' if expected in r.bound_drivers else 'WARN')) }}
driver_detail: >-
{%- if (r.display_devices | length) == 0 -%}
no display-class PCI device on this machine, so no DRM driver can be bound to one.
Measured from /sys/bus/pci/devices.
{%- else -%}
{% for p in r.display_devices %}{{ p.address }}
{{ p.description | default(((p.vendor_id | default('?', true)) ~ ':' ~ (p.device_id | default('?', true))), true) }}
-> driver {{ p.driver | default('NONE BOUND', true) }}{{ '; ' if not loop.last else '' }}{% endfor %}.
{{ ('DRM cards: ' ~ (g.drm_cards | map(attribute='card') | zip(g.drm_cards | map(attribute='driver'))
| map('join', '=') | join(', ')) ~ '. ') if (g.drm_cards | length) > 0
else 'No /sys/class/drm/card* entry exists, which is consistent with no driver being bound. ' }}
Module load state (/proc/modules, corroboration only — a built-in driver never appears
there, the bound-driver symlink above is what decides):
{{ g.modules | dict2items | map(attribute='key') | zip(g.modules | dict2items | map(attribute='value') | map('string'))
| map('join', '=') | join(', ') }}.
{%- if (r.bound_drivers | length) == 0 %}
NOTHING IS BOUND to the display device. That is why there is no render node: the DRM
subsystem creates /dev/dri/renderD* only when a driver attaches. Check whether the
module exists for this kernel and whether it was blacklisted or failed to probe.
{%- elif expected in r.bound_drivers %}
This is the expected driver for this site (node_health_graphics_expected_driver).
{%- else %}
EXPECTED `{{ expected }}`, GOT `{{ r.bound_drivers | join(', ') }}`. The device still
works, so this is not an outage — but the two DRM drivers do not share a userspace
media stack, and a libva/iHD build that transcodes correctly against one is not
automatically right for the other. Confirm Plex still hardware-transcodes before
treating this as benign.
{%- endif %}
{%- endif -%}
# INFO ONLY, AND THAT IS A DELIBERATE RULING, NOT AN OVERSIGHT:
#
# GuC/HuC firmware not loaded (i915.enable_guc=0) — the accepted state on these nodes. It
# costs low-power H.264 encode QUALITY, nothing else; decode and encode both still work
# through the media engines. Grading it would put a permanent WARN on a configuration the
# operator has chosen.
# No host VAAPI userspace / no `vainfo` — NOT a defect. Plex ships its own libva and media
# driver inside its container, so the host needs neither. Flagging it would invite someone
# to `apt install vainfo` on a node to clear a health check, and this role installs nothing.
#
# The media ENGINES are the fact worth watching in this line: vcs* (video) and vecs*
# (video-enhancement) are what a transcode actually runs on.
- name: Assess — media engines, GuC/HuC firmware and host VAAPI userspace (informational)
ansible.builtin.set_fact:
node_health_checks: "{{ node_health_checks + [{
'id': 'graphics.media_stack',
'title': 'Media engines, GuC/HuC firmware and host VAAPI userspace (informational)',
'status': ('SKIP' if (node_health_render.display_devices | length) == 0 else 'INFO'),
'detail': media_detail }] }}"
vars:
ansible_become: false
g: "{{ node_health_facts.graphics }}"
r: "{{ node_health_render }}"
# `.get()` rather than a subscript: uc only carries the two Intel drivers, and a site
# that set the expected driver to amdgpu must get a clean "no such parameters" line
# instead of an undefined-key traceback.
guc: "{{ g.uc.get(node_health_graphics_expected_driver) }}"
guc_text: >-
{{ ('enable_guc=' ~ guc.enable_guc.value) if (guc is mapping and guc.enable_guc.value is not none)
else ('NOT READ — ' ~ (guc.enable_guc.reason | default('no reason recorded', true))
if guc is mapping
else 'no /sys/module/' ~ node_health_graphics_expected_driver ~ '/parameters at all') }}
media_detail: >-
{%- if (r.display_devices | length) == 0 -%}
no display-class PCI device on this machine, so there is no media engine, no GuC/HuC
firmware and nothing to transcode with.
{%- else -%}
media engines:
{{ (r.media_engines | join(', ')) if (r.media_engines | length) > 0
else 'NONE exposed under /sys/class/drm/card*/engine — a bound driver with no vcs*/vecs* engine cannot do media work' }}
(all engines: {{ (g.drm_cards | map(attribute='engines') | flatten | unique | sort | join(', ')) | default('none', true) }}).
GuC/HuC: {{ guc_text }}.
{{ ('Kernel cmdline carries ' ~ (g.cmdline_params | join(' '))) if (g.cmdline_params | length) > 0
else 'No i915/xe parameter on the kernel cmdline' }}.
GuC/HuC being off is a KNOWN, ACCEPTED state here and is never graded: it costs
low-power H.264 encode quality only, and both decode and encode still run on the
engines above.
Host VAAPI userspace: vainfo
{{ ('present at ' ~ node_health_facts.tools.vainfo) if node_health_facts.tools.vainfo
else 'NOT installed on this host' }}
— also never graded, because Plex ships its own libva and media driver inside its
container, so the host needs none. This role installs nothing.
{%- endif -%}
# --- 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 -%}