Files
home-network-k3s-impl/infra/ansible/roles/node_health/tasks/assess.yml
T
jakman85andClaude Opus 5 0b3db6e778 fix(node_health): SMART verdict was wrong by 4096x — judge on portable signals
The first real SMART run FAILED k3s-prod-1's drive on reallocated=61440. That verdict
was WRONG, and the failure mode is instructive: the check trusted a VENDOR-DEFINED raw
value as if it were an ATA-standard sector count.

Micron TN-FD-20 ("SMART Attributes for the C400 SSD") names attribute 5 "Reallocated
BLOCK Count" and defines its raw as grown-bad-blocks x Block_Sector_Count, where
Block_Sector_Count is 256 x 16 = 4096 for the 128Gb-NAND parts (256GB/512GB). This is
the 512GB C400-MTFDDAC512MAM: 61440 / 4096 = 15. Triply corroborated on the drive itself
— attr 170 Grown_Failing_Block_Ct = 15, attr 196 Reallocated_Event_Count = 15, attr 5
normalised value 100 against thresh 10, and `when_failed` empty on every attribute.
FIFTEEN grown bad blocks, not sixty-one thousand bad sectors.

smartctl(8) states it outright: since ATA/ATAPI-4 rev 4 the meaning of raw Attribute
fields is "entirely vendor-specific". So a raw value can never be a portable verdict.

FAIL now comes only from signals that mean the same thing on every drive: the drive's own
smart_status.passed; NORMALISED value vs the MANUFACTURER's own threshold via smartctl's
when_failed ("now"/"past"/""), split Pre-fail -> FAIL and Old_age-or-past -> WARN; the
ATA-standard counters 197/198/187 (199 CRC is WARN-only — it is link, not media); and for
NVMe critical_warning / media_errors / percentage_used. Wear reads from the NORMALISED
lifetime indicator (202/231/233, or NVMe percentage_used) — a percentage by definition.

Vendor raw counts are still collected and PRINTED WITH THEIR NUMBERS, and may raise a
WARN, but can never drive a FAIL. Attribute 5's raw is now shown beside the normalised
reading that contradicts it, labelled vendor-encoded, with a mechanically-derived
"(= 15 x 4096)". Endurance attributes are matched by THE NAME smartctl's drivedb resolved
for that model, not by bare ID — drivedb is the portability layer; on an unrecognised
drive the name is Unknown_Attribute, nothing matches, and the field stays null with no
claim made. The STORAGE section now prints 170/172/173/174/189/206, whose previous
omission is exactly what let the misread stand unchallenged.

Also documented from TN-FD-20: on this family attr 197 "will always be 0, as reallocation
will be done on-the-fly" — so pending=0 is NOT evidence of health here. The role reports
the number and infers nothing from it.

Branches real hardware cannot exercise were verified with a harness rendering the actual
role expressions against synthetic smartctl payloads: virtio/no-stdout -> SKIP; NVMe
healthy -> PASS; NVMe degraded -> FAIL; a genuinely dying ATA drive (attr 5 normalised 8
<= thresh 10, pending 48) -> FAIL driven by the threshold trip and standard counters, not
the raw; unknown-vendor attrs -> PASS with the unnamed counter ignored; failed-in-the-past
-> WARN.

NEW VERDICTS. k3s-prod-1: 0 FAIL / 4 WARN / 12 PASS / 1 SKIP — SMART is a WARN naming the
real findings (31% of rated life consumed, 15 grown bad blocks, 187 erase failures, 32
unexpected power losses on a drive with no power-loss protection). k3s-dev-1: unchanged,
all three virtio disks still SKIP with their stated reason.

BREAKING (JSON artifact): the fact `reallocated_sectors` is renamed and gone — that name
asserted a unit ATA does not guarantee. Replaced by realloc_attr (the whole attribute,
normalised + raw), realloc_events, and grown_bad_blocks. Nothing in the repo reads it.

Serves: R-ENV-03, R-OBS-04.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 00:40:29 -05:00

677 lines
38 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 ~ '%)') }}
- 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 '')) }}