The fault is BOOT-TO-BOOT NON-DETERMINISTIC — the same machine booted correctly two boots
earlier — so it has to be detected on every health run, not diagnosed once. And node 1 is
exposed to the identical lottery on every reboot while currently looking healthy.
firmware.efi_memattr — reads the kernel ring buffer and distinguishes six states:
Failed to map EFI Memory Attributes table @ 0x.. -> FAIL (memory lost NOW)
Unexpected EFI Memory Attributes table version <N> -> WARN (corrupt table still published;
intact only by luck)
[Firmware Bug]: Corrupted EFI ... detected! -> PASS (the >=6.13 fix working)
no memattr line, log reaches early boot -> PASS (no table / accepted / not UEFI)
no memattr line, log does NOT reach early boot -> SKIP
dmesg unreadable -> SKIP
memory.physical_total — deliberately a SEPARATE check, not folded in: it measures the SYMPTOM
with no kernel log at all, so an unreadable-dmesg node is still not unmeasured. Two sub-verdicts,
worst wins: /proc/zoneinfo present-vs-managed (world-readable, works on metal and VMs — sum of
managed IS MemTotal) and MemTotal vs the DIMM total from dmidecode. Thresholds 10% WARN / 25%
FAIL, set from measured baselines: 2.0% prod-1, 2.1% workstation, 2.2% dev VM, vs 83.4% prod-2.
THREE GUARDS AGAINST A FALSE PASS, because "no memattr line" must never silently mean "fine":
1. dmesg failure yields available:false with the exact reason (rc, uid, kernel.dmesg_restrict) -> SKIP
2. the ring buffer WRAPS and every memattr line prints in the first fraction of a second, so the
collector records a boot_covered anchor (Linux version / BIOS-provided physical RAM map); no
memattr line WITHOUT that anchor is a SKIP, not a PASS
3. memory.physical_total needs no kernel log at all
Verified by forcing the unprivileged path: SKIP with the EPERM reason while zoneinfo still PASSed.
VERDICTS, confirmed live:
k3s-prod-1 WARN "EXPOSED, not immune ... survived only because its garbage version field read
1030, which is > 2 ... one unlucky boot from the failure state" + PASS 2.0%
k3s-prod-2 FAIL "THIS NODE HAS LOST MEMORY TO THE BUG ... at 0xbeeb0018" + FAIL 83.4%/83.6%
k3s-dev-* PASS both, NO false finding: "no UEFI config-table line ... did not boot via UEFI.
Log reaches back to early boot, so that absence is measured, not assumed."
Both failure texts carry the remediation verbatim, including that there is NO kernel cmdline
workaround because efi_memattr_init() runs before any efi= bail-out.
Root-gating follows the role's existing pattern: --privileged re-runs THE SAME collector under
become, only when the unprivileged pass actually failed to read something. Reimplementing the log
grep and SMBIOS parse in Jinja would have been a second source of truth for the same fact.
LATENT BUG FIXED in passing: the collector's run() used bare shutil.which(), so dmidecode (in
/usr/sbin) was invisible under the unprivileged k3s PATH and produced a FALSE "not installed"
skip reason. Now shares the sbin-aware which() collect_tools() already had.
INCIDENTAL FINDING recorded in §0b: node 1's DIMMs are NOT a matched pair — DIMM1 Micron
8ATF2G64HZ-3G2E1 (DDR4-3200), DIMM3 Micron 16ATF2G64HZ-2G6E1 (DDR4-2667). Both 16 GB so capacity
is right, but different rank organisation and rated speed, so the pair runs at 2667. Not a fault;
recorded because §2 requires the three nodes to end up symmetric and "2×16 GB Micron" hid it.
Serves: R-ENV-01, R-ENV-03, R-OBS-04. Task: T-7.1.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
878 lines
50 KiB
YAML
878 lines
50 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 CAUSE-INDEPENDENT MEMORY-LOSS CHECK. 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. Thresholds and their measured baselines: defaults/main.yml.
|
|
- 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
|
|
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.
|
|
phys_status: >-
|
|
{{ (['PASS', 'WARN', 'FAIL'] | select('in', [z_status, d_status]) | list | last)
|
|
| default('SKIP', true) }}
|
|
# What the kernel log says, IF it was read — quoted here only as attribution for a
|
|
# shortfall this check measured on its own. The verdict above never depends on it.
|
|
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 '') }}
|
|
|
|
- name: Assess — swap (kubelet requires it off)
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'memory.swap',
|
|
'title': 'Swap disabled (kubelet prerequisite)',
|
|
'status': swap_status,
|
|
'detail': swap_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
m: "{{ node_health_facts.memory }}"
|
|
swap_status: >-
|
|
{{ 'SKIP' if m.swap_total_kb is not number
|
|
else ('PASS' if m.swap_total_kb | int == 0 else 'WARN') }}
|
|
swap_detail: >-
|
|
{{ 'swap state unreadable' if m.swap_total_kb is not number
|
|
else ('no swap active' if m.swap_total_kb | int == 0
|
|
else ((m.swap_total_kb | int / 1048576) | round(2) | string
|
|
~ ' GiB swap active — roles/node_baseline runs swapoff and neutralises
|
|
the fstab entry; this node has not been baselined yet')) }}
|
|
|
|
- name: Assess — ECC / EDAC correctable errors
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'memory.ecc',
|
|
'title': 'ECC memory errors (EDAC correctable/uncorrectable counters)',
|
|
'status': ecc_status,
|
|
'detail': ecc_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
e: "{{ node_health_facts.memory.edac }}"
|
|
ecc_status: >-
|
|
{{ 'SKIP' if not (e.controllers_present | bool)
|
|
else ('FAIL' if (e.ue_total | int) > 0
|
|
else ('WARN' if (e.ce_total | int) > 0 else 'PASS')) }}
|
|
ecc_detail: >-
|
|
{{ ('no EDAC memory controller is bound (' ~ ('/sys/devices/system/edac/mc exists but is empty'
|
|
if e.sysfs_present | bool else 'no EDAC sysfs at all')
|
|
~ ') — this platform does not report ECC status. Non-ECC consumer RAM or a VM;
|
|
"no errors" CANNOT be confirmed on this hardware.')
|
|
if not (e.controllers_present | bool)
|
|
else ((e.controllers | length) | string ~ ' controller(s), correctable='
|
|
~ e.ce_total | string ~ ', uncorrectable=' ~ e.ue_total | string) }}
|
|
|
|
# --- CPU ----------------------------------------------------------------------------
|
|
- name: Assess — CPU thermal throttling
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'cpu.throttle',
|
|
'title': 'CPU thermal throttling since boot',
|
|
'status': thr_status,
|
|
'detail': thr_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
t: "{{ node_health_facts.cpu.throttle }}"
|
|
thr_status: >-
|
|
{{ 'SKIP' if t is none
|
|
else ('PASS' if ((t.core_throttle_count_total | int) + (t.package_throttle_count | int)) == 0
|
|
else 'WARN') }}
|
|
thr_detail: >-
|
|
{{ 'no thermal_throttle counters (VM guest or unsupported CPU)' if t is none
|
|
else ('core throttle events=' ~ t.core_throttle_count_total | string
|
|
~ ' (' ~ t.core_throttle_total_time_ms | string ~ ' ms), package events='
|
|
~ t.package_throttle_count | string) }}
|
|
|
|
- name: Assess — CPU inventory and frequency scaling
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'cpu.inventory',
|
|
'title': 'CPU model, topology and frequency scaling',
|
|
'status': 'INFO',
|
|
'detail': cpu_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
c: "{{ node_health_facts.cpu }}"
|
|
cpu_detail: >-
|
|
{{ (c.model | default('unknown')) ~ ' — ' ~ (c.cpus | default('?')) ~ ' logical CPU(s), '
|
|
~ (c.sockets | default('?')) ~ ' socket(s) x ' ~ (c.cores_per_socket | default('?'))
|
|
~ ' core(s) x ' ~ (c.threads_per_core | default('?')) ~ ' thread(s); load '
|
|
~ (c.loadavg | default([]) | join(' ')) ~ '; '
|
|
~ ('governor=' ~ c.scaling.governor ~ ' driver=' ~ c.scaling.driver
|
|
~ ' cur=' ~ ((c.scaling.cur_khz | int) / 1000) | round | int | string ~ 'MHz'
|
|
~ ' max=' ~ ((c.scaling.max_khz | int) / 1000) | round | int | string ~ 'MHz'
|
|
if c.scaling is mapping else 'no cpufreq scaling exposed') }}
|
|
|
|
- name: Assess — hardware virtualisation (KubeVirt prerequisite, R-PLAT-03)
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'cpu.virtualisation',
|
|
'title': 'Hardware virtualisation available (VT-x/AMD-V + /dev/kvm)',
|
|
'status': virt_status,
|
|
'detail': virt_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
c: "{{ node_health_facts.cpu }}"
|
|
virt_status: >-
|
|
{{ 'PASS' if (c.hardware_virt_flag | bool and c.kvm_device | bool) else 'WARN' }}
|
|
virt_detail: >-
|
|
{{ 'vmx/svm flag=' ~ c.hardware_virt_flag | string ~ ', /dev/kvm='
|
|
~ c.kvm_device | string
|
|
~ (', lscpu Virtualization=' ~ c.virtualization if c.virtualization else '')
|
|
~ (', running as a guest of ' ~ c.hypervisor_vendor if c.is_virtual_guest | bool else '') }}
|
|
|
|
# --- Thermals -----------------------------------------------------------------------
|
|
- name: Assess — temperatures
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'thermal.max',
|
|
'title': 'Hottest reported sensor',
|
|
'status': temp_status,
|
|
'detail': temp_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
th: "{{ node_health_thresholds }}"
|
|
hottest: "{{ (node_health_temps_c | max) if (node_health_temps_c | length) > 0 else none }}"
|
|
temp_status: >-
|
|
{{ 'SKIP' if hottest is not number
|
|
else ('FAIL' if hottest | float >= th.temp_fail_c
|
|
else ('WARN' if hottest | float >= th.temp_warn_c else 'PASS')) }}
|
|
temp_detail: >-
|
|
{{ 'no thermal sensor exposed (typical in a VM)' if hottest is not number
|
|
else (hottest | string ~ 'C hottest of ' ~ (node_health_temps_c | length) | string
|
|
~ ' sensors; zones: '
|
|
~ (node_health_facts.thermal.zones
|
|
| map(attribute='type') | join(', ')) ) }}
|
|
|
|
# --- Pressure-stall (PSI) -----------------------------------------------------------
|
|
- name: Assess — pressure stall (CPU, memory, I/O)
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'pressure.' ~ item,
|
|
'title': 'PSI ' ~ item ~ ' — some avg60 (% of wall time stalled)',
|
|
'status': psi_status,
|
|
'detail': psi_detail }] }}"
|
|
loop: [cpu, memory, io]
|
|
vars:
|
|
ansible_become: false
|
|
th: "{{ node_health_thresholds }}"
|
|
psi: "{{ node_health_facts.pressure[item] | default(none) }}"
|
|
avg60: "{{ psi.some.avg60 | default(none) if psi is mapping else none }}"
|
|
psi_status: >-
|
|
{{ 'SKIP' if avg60 is not number
|
|
else ('FAIL' if avg60 | float >= th.psi_some_avg60_fail
|
|
else ('WARN' if avg60 | float >= th.psi_some_avg60_warn else 'PASS')) }}
|
|
psi_detail: >-
|
|
{{ '/proc/pressure/' ~ item ~ ' unavailable (kernel without CONFIG_PSI, or psi=0)'
|
|
if avg60 is not number
|
|
else ('some avg10=' ~ psi.some.avg10 | string ~ ' avg60=' ~ psi.some.avg60 | string
|
|
~ ' avg300=' ~ psi.some.avg300 | string
|
|
~ (' | full avg60=' ~ psi.full.avg60 | string if psi.full is defined else '')) }}
|
|
|
|
# --- Network ------------------------------------------------------------------------
|
|
- name: Assess — link state and speed
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'network.link',
|
|
'title': 'Physical NIC link state and negotiated speed',
|
|
'status': link_status,
|
|
'detail': link_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
th: "{{ node_health_thresholds }}"
|
|
up: "{{ node_health_nics_up }}"
|
|
slow: >-
|
|
{{ up | selectattr('speed_mbps', 'number')
|
|
| selectattr('speed_mbps', 'lt', th.nic_min_speed_mbps | int) | list }}
|
|
link_status: >-
|
|
{{ 'FAIL' if (up | length) == 0
|
|
else ('WARN' if (slow | length) > 0 else 'PASS') }}
|
|
link_detail: >-
|
|
{{ 'no physical interface is up' if (up | length) == 0
|
|
else (up | map(attribute='name') | zip(up | map(attribute='speed_mbps'))
|
|
| map('join', '=') | join('Mb/s, ') ~ 'Mb/s'
|
|
~ ' (driver ' ~ (up | map(attribute='driver') | join(', ')) ~ ')') }}
|
|
|
|
- name: Assess — link error counters
|
|
ansible.builtin.set_fact:
|
|
node_health_checks: "{{ node_health_checks + [{
|
|
'id': 'network.errors',
|
|
'title': 'NIC error counters since boot (rx/tx errors, CRC, carrier)',
|
|
'status': ('PASS' if err_total | int == 0 else 'WARN'),
|
|
'detail': err_detail }] }}"
|
|
vars:
|
|
ansible_become: false
|
|
up: "{{ node_health_nics_up }}"
|
|
err_keys: [rx_errors, tx_errors, rx_crc_errors, rx_frame_errors, rx_over_errors, tx_carrier_errors]
|
|
err_total: >-
|
|
{{ up | map(attribute='statistics') | map('dict2items') | flatten
|
|
| selectattr('key', 'in', err_keys) | map(attribute='value')
|
|
| select('number') | list | sum }}
|
|
drop_total: >-
|
|
{{ up | map(attribute='statistics') | map('dict2items') | flatten
|
|
| selectattr('key', 'in', ['rx_dropped', 'tx_dropped']) | map(attribute='value')
|
|
| select('number') | list | sum }}
|
|
err_detail: >-
|
|
{{ err_total | string ~ ' error(s), ' ~ drop_total | string ~ ' drop(s) across '
|
|
~ (up | map(attribute='name') | join(', ')) }}
|
|
|
|
# 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: drivers/firmware/efi/memattr.c
|
|
# multiplies two never-validated fields (num_entries x desc_size) and memblock_reserve()s
|
|
# the product — everything from the table address to the top of RAM. The kernel prints
|
|
# exactly one of two lines, and WHICH one is decided by whether the garbage `version` field
|
|
# happens to read > 2:
|
|
#
|
|
# "Failed to map EFI Memory Attributes table @ 0x..." version read <= 2, the kernel
|
|
# TRUSTED the table. The node has lost the memory. FAIL.
|
|
# "Unexpected EFI Memory Attributes table version <N>" version read > 2, the kernel
|
|
# bailed out first. Memory intact THIS BOOT — but the firmware is still publishing a
|
|
# corrupt table, and the outcome is re-decided on every single boot. WARN, not PASS:
|
|
# k3s-prod-1 and k3s-prod-2 differ by LUCK, not by immunity, and k3s-prod-2 itself
|
|
# booted with its full 15.9 GiB two boots before it lost 13.3 GiB. A node one
|
|
# unlucky boot from losing 83% of its RAM is not passing a health check.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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 }}"
|
|
ma_state: "{{ k.memattr_state | default(none) }}"
|
|
memattr_status: >-
|
|
{{ 'SKIP' if not (k.available | bool)
|
|
else ('FAIL' if ma_state == 'failed_to_map'
|
|
else ('WARN' if ma_state == 'bad_version'
|
|
else ('PASS' if ma_state == 'corrupt_rejected'
|
|
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 symptom, measured independently of this log (see memory.physical_total).
|
|
memattr_corroboration: >-
|
|
{{ (' Measured independently of this log: MemTotal '
|
|
~ ((z.managed_kb | int) / 1048576) | round(1) | string ~ ' GiB of '
|
|
~ ((z.present_kb | int) / 1048576) | round(1) | string ~ ' GiB present, i.e. '
|
|
~ z.unmanaged_pct | string ~ '% of the RAM this machine has is reserved away
|
|
from the allocator (/proc/zoneinfo) — check memory.physical_total.')
|
|
if ((z.available | bool) and z.unmanaged_pct is number) else '' }}
|
|
# 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' -%}
|
|
THIS NODE HAS LOST MEMORY TO THE BUG. The kernel failed to map the EFI Memory
|
|
Attributes table at {{ k.memattr_table_address | default('an unreported address') }}
|
|
and reserved memory from the corrupt table anyway — everything from that address
|
|
upward.{{ memattr_evidence }}{{ memattr_corroboration }} {{ memattr_fix }}
|
|
{%- elif ma_state == 'bad_version' -%}
|
|
EXPOSED, not immune. The firmware publishes a CORRUPT EFI Memory Attributes table
|
|
here too; this boot survived only because its garbage version field read
|
|
{{ k.memattr_version | default('> 2') }}, which is > 2, so the kernel bailed out
|
|
before reserving anything. The outcome is re-decided on EVERY boot: if that field
|
|
reads <= 2 the kernel trusts the table and reserves gigabytes. Memory is intact right
|
|
now and this node is one unlucky boot from the failure state.{{ memattr_evidence }}
|
|
{{ 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). Memory is
|
|
intact and stays intact across reboots; no action.{{ memattr_evidence }}
|
|
{%- 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 -%}
|