k3s-prod-3 online at the planned 192.168.2.12 (serial 8CC015307N, same chassis/BIOS/CPU),
enabled in inventory, vaulted become confirmed, package floor installed. Health: 0 FAIL /
4 WARN / 14 PASS / 1 SKIP — the only node with zero FAILs. WD Blue WDS500G2B0A SATA SSD at
100% life, 3213 h, 0 grown bad blocks, 0 erase fails. Matched Crucial 2x16 GB pair — the only
node with symmetric memory.
*** NODE 3 IS NOT IMMUNE TO THE §0b BUG. *** Its firmware DOES publish a MAT
(MEMATTR=0xbeece018); the ABSENCE of a memattr diagnostic line means the kernel read a VALID
table, which is the opposite of immunity. There are THREE outcomes, not two: bail-out on a
garbage version >2, a valid parse with a sane size, and the catastrophic path. Seven boots across
three machines show the MAT address MOVES BOOT-TO-BOOT on every node with settings constant, and
each node has been seen on more than one path. Blast radius widens from two nodes to ALL THREE;
all three need the >= 6.13 kernel.
THE CORRUPTING BYTES ARE THE OS BOOT CHAIN, not firmware and not DRAM: garbage version
1463964749 = 0x5742544D appeared on node 2 AND node 3 — two different machines, identical bytes.
The overwriter is the shared shim/GRUB/EFI-stub/initrd placement writing into the page the
firmware pointed at. So initrd size, module set and boot device are part of the causal chain.
BIOS-SETTING HYPOTHESIS: DISPROVEN, and the three-way diff cannot support it even in principle.
All 240 attributes read on all three; firmware identical fleet-wide. The discriminating set (both
working nodes agree, broken differs) is seven attributes, of which:
- Fast Boot is the ONLY live candidate, and only as a probability-shifter — HP documents it
'skips some preboot initialization steps', which changes the heap top and hence where the MAT
lands. Different mechanism from the previously-killed MRC-caching theory; that stays dead.
- Legacy Boot Options is AFFIRMATIVELY INERT: 'Configure Legacy Support and Secure Boot' =
'Legacy Support Disable and Secure Boot Enable' on ALL THREE, Option ROM Launch Policy =
'All UEFI' on all three. No CSM is built on node 2 either. A naive diff gets this wrong.
- the rest are coincidence. After Power Loss is ELIMINATED by node 3, which works and shares
node 2's Power Off — though it is now a §1 checklist violation on TWO nodes.
FATAL CONFOUND: the 1=3!=2 partition is perfectly collinear with node 2 being the only
single-DIMM AND the only NVMe-booting node. Adding node 3 did not break the confound, it DOUBLED
it. No setting can be the cause anyway: node 2 booted good once and bad twice with settings held
constant.
EXPERIMENT DESIGN RECORDED because the obvious approach is statistically worthless: p(bad) is
known only to n=3, 95% CI ~0.09-0.99, so even 8 clean boots prove nothing. Elimination needs ~20
boots/arm; an odds shift needs ~50. Zero-touch first step: 20 reboots of node 2 unchanged, logging
MEMATTR address vs outcome — both failures were at exactly 0xbeeb0018 and all five successes
elsewhere, so if outcome maps onto a narrow address range the mechanism is settled without
touching anything.
ROLE BUG FIXED: node 3's WD drive reports a SMART threshold for only 1 of 25 attributes, and
smartctl OMITS the key rather than emitting 0 — summary.txt.j2 dereferenced a.thresh bare in five
places and the report task died, so no artifact was written. Added a thr() macro rendering 'n/a'
(deliberately not 0, which would read as a real ATA cannot-trip threshold). Would have hit any
drive of this class.
Also: three nodes, three different disk models, only one NVMe, and every node has a single disk
with no unpartitioned spare — so §2's identical-layout requirement is unmet on all three.
Serves: R-ENV-01, R-ENV-03, R-OBS-04. Task: T-7.1.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
188 lines
15 KiB
Django/Jinja
188 lines
15 KiB
Django/Jinja
{# ===========================================================================
|
||
node_health human summary. Rendered ONCE (tasks/report.yml) and used for both
|
||
the on-screen report and the .txt artifact, so the two can never drift.
|
||
|
||
Detail strings are normalised with regex_replace('\s+',' ') because the check
|
||
details are authored as YAML folded scalars in assess.yml and can carry stray
|
||
newlines/indentation from that folding.
|
||
=========================================================================== #}
|
||
{% macro fmt(text, width=74, pad=10) -%}
|
||
{{ text | regex_replace('\s+', ' ') | trim | wordwrap(width) | indent(pad, first=True) }}
|
||
{%- endmacro %}
|
||
{% macro gib(n) -%}
|
||
{{ (n | int / 1073741824) | round(1) }} GiB
|
||
{%- endmacro %}
|
||
{# smartctl OMITS `thresh` entirely for any attribute whose threshold the drive does not
|
||
report (surfaced 2026-09-03 by k3s-prod-3's WDC WDS500G2B0A, which reports one for 1 of
|
||
its 25 attributes) — an ABSENT key, not thresh=0, so a bare `a.thresh` raised
|
||
"object of type 'dict' has no attribute 'thresh'" and killed the whole report. Render it
|
||
as `n/a` (the same token this template already uses for unreported readings) rather than
|
||
defaulting to 0, which would falsely read as a real ATA "cannot trip" threshold. The
|
||
collector already guards this — tasks/collect_smart.yml uses selectattr('thresh',
|
||
'defined') and `a.thresh | default(0)` — so only the template was exposed. #}
|
||
{% macro thr(a) -%}
|
||
{{ a.thresh | default('n/a') }}
|
||
{%- endmacro %}
|
||
================================================================================
|
||
NODE HEALTH — {{ inventory_hostname }} ({{ ansible_host | default('n/a') }})
|
||
collected {{ node_health_facts.collected_at }} as uid {{ node_health_facts.collector_uid }}
|
||
================================================================================
|
||
|
||
VERDICT {{ node_health_counts.FAIL }} FAIL · {{ node_health_counts.WARN }} WARN · {{ node_health_counts.PASS }} PASS · {{ node_health_counts.SKIP }} SKIP · {{ node_health_counts.INFO }} INFO
|
||
{{ 'privileged checks RAN' if node_health_root_available | bool else 'privileged checks SKIPPED — root not available' }}
|
||
|
||
--------------------------------------------------------------------------------
|
||
CHECKS
|
||
--------------------------------------------------------------------------------
|
||
{% for c in node_health_checks %}
|
||
[{{ '%-4s' | format(c.status) }}] {{ c.id }} — {{ c.title }}
|
||
{{ fmt(c.detail) }}
|
||
{% endfor %}
|
||
|
||
--------------------------------------------------------------------------------
|
||
MACHINE — {{ node_health_facts.dmi.sys_vendor | default('?') }} {{ node_health_facts.dmi.product_name | default('?') }}
|
||
--------------------------------------------------------------------------------
|
||
OS {{ node_health_facts.os.pretty_name | default('?') }}
|
||
Kernel {{ node_health_facts.os.kernel | default('?') }}
|
||
Uptime {{ (node_health_facts.os.uptime_seconds | float / 86400) | round(2) }} days
|
||
BIOS {{ node_health_facts.dmi.bios_vendor | default('?') }} {{ node_health_facts.dmi.bios_version | default('?') }} ({{ node_health_facts.dmi.bios_date | default('?') }}){% if node_health_facts.dmi.ec_firmware_release %}, EC {{ node_health_facts.dmi.ec_firmware_release }}{% endif %}
|
||
|
||
Board {{ node_health_facts.dmi.board_vendor | default('?') }} {{ node_health_facts.dmi.board_name | default('?') }}
|
||
CPU {{ node_health_facts.cpu.model | default('?') }}
|
||
{{ node_health_facts.cpu.cpus | default('?') }} logical / {{ node_health_facts.cpu.sockets | default('?') }} socket(s) x {{ node_health_facts.cpu.cores_per_socket | default('?') }} core(s) x {{ node_health_facts.cpu.threads_per_core | default('?') }} thread(s)
|
||
virtualisation: {{ node_health_facts.cpu.virtualization | default('none reported') }}, /dev/kvm={{ node_health_facts.cpu.kvm_device }}
|
||
{% if node_health_facts.cpu.scaling %}
|
||
scaling: {{ node_health_facts.cpu.scaling.driver }}/{{ node_health_facts.cpu.scaling.governor }}, {{ (node_health_facts.cpu.scaling.min_khz | int / 1000) | round | int }}–{{ (node_health_facts.cpu.scaling.max_khz | int / 1000) | round | int }} MHz (now {{ (node_health_facts.cpu.scaling.cur_khz | int / 1000) | round | int }} MHz)
|
||
{% endif %}
|
||
Memory {{ (node_health_facts.memory.mem_total_kb | int / 1048576) | round(1) }} GiB total, {{ (node_health_facts.memory.mem_available_kb | int / 1048576) | round(1) }} GiB available, swap {{ (node_health_facts.memory.swap_total_kb | int / 1048576) | round(2) }} GiB
|
||
ECC/EDAC {{ 'controllers: ' ~ (node_health_facts.memory.edac.controllers | map(attribute='name') | join(', ')) ~ ' CE=' ~ node_health_facts.memory.edac.ce_total | string ~ ' UE=' ~ node_health_facts.memory.edac.ue_total | string if node_health_facts.memory.edac.controllers_present else 'not exposed (no EDAC controller bound — ECC status UNKNOWN on this platform)' }}
|
||
{% set mz = node_health_facts.memory.zones %}
|
||
{% set md = node_health_facts.memory.devices %}
|
||
Present RAM {{ ((mz.present_kb | int / 1048576) | round(1) | string ~ ' GiB present / ' ~ (mz.managed_kb | int / 1048576) | round(1) | string ~ ' GiB managed — ' ~ mz.unmanaged_pct | string ~ '% never handed to the allocator') if mz.available and mz.unmanaged_pct is not none else ('NOT READ — ' ~ (mz.reason | default('no reason recorded', true))) }}
|
||
DIMMs {{ ((md.installed_kb | int / 1048576) | round(1) | string ~ ' GiB installed in ' ~ md.populated_slots | string ~ ' of ' ~ md.slots | string ~ ' slot(s)') if md.available else ('NOT READ — ' ~ (md.reason | default('no reason recorded', true)) | regex_replace('\s+', ' ') | trim | wordwrap(64) | indent(14)) }}
|
||
{% for dimm in md.devices | default([]) if dimm.size_kb %}
|
||
{{ '%-8s' | format(dimm.locator | default('?', true)) }} {{ '%-8s' | format(dimm.size_raw) }} {{ dimm.type | default('?', true) }} {{ dimm.speed | default('', true) }} {{ dimm.manufacturer | default('', true) }} {{ dimm.part_number | default('', true) }}
|
||
{% endfor %}
|
||
{% set klog = node_health_facts.klog %}
|
||
EFI memattr {{ (klog.memattr_lines | join(' | ')) if klog.memattr_lines else (('kernel log NOT READ — ' ~ (klog.reason | default('no reason recorded', true))) if not klog.available else ('no memattr line in the kernel log' ~ ('' if klog.boot_covered else ' — BUT the ring buffer no longer reaches early boot, so that is an UNKNOWN, not a pass'))) | regex_replace('\s+', ' ') | trim | wordwrap(64) | indent(14) }}
|
||
Load {{ node_health_facts.cpu.loadavg | default([]) | join(' ') }}
|
||
|
||
--------------------------------------------------------------------------------
|
||
STORAGE
|
||
--------------------------------------------------------------------------------
|
||
{% for d in node_health_facts.storage.disks %}
|
||
{{ d.path }} {{ gib(d.size_bytes) }} {{ 'rotational' if d.rotational else 'non-rotating' }} {{ d.transport | default('?', true) }}{% if d.fstype %} whole-device {{ d.fstype }}{% endif %}{% if d.mountpoints %} mounted at {{ d.mountpoints | join(',') }}{% endif %}
|
||
|
||
model {{ d.model | default('(none reported)', true) }}{% if d.firmware_rev %} fw {{ d.firmware_rev }}{% endif %}{% if d.serial %} s/n {{ d.serial }}{% endif %}
|
||
|
||
{% if d.scsi_counters %}
|
||
kernel I/O completed={{ d.scsi_counters.iodone_cnt }} errors={{ d.scsi_counters.ioerr_cnt }} timeouts={{ d.scsi_counters.iotmo_cnt }}
|
||
{% endif %}
|
||
{% set sm = (node_health_smart | selectattr('path', 'equalto', d.path) | list | first) | default(none) %}
|
||
{% if sm and sm.available %}
|
||
SMART overall-health={{ 'PASSED' if sm.passed else 'FAILED' }} power_on_hours={{ sm.power_on_hours | default('?') }} power_cycles={{ sm.power_cycles | default('?') }}
|
||
lifetime_remaining={{ (sm.lifetime_remaining_pct | string ~ '%') if sm.lifetime_remaining_pct is not none else 'not reported' }} temp={{ (sm.temperature_c | string ~ 'C') if sm.temperature_c is not none else 'not reported by this drive' }}
|
||
pending(197)={{ sm.pending_sectors | default('?') }} offline_uncorrectable(198)={{ sm.offline_uncorrectable | default('?') }} reported_uncorrect(187)={{ sm.reported_uncorrect | default('?') }} crc(199)={{ sm.crc_errors | default('?') }}
|
||
{% if sm.failing_now or sm.failed_past %}
|
||
THRESHOLD TRIPS{% for a in sm.failing_now %} [{{ a.id }} {{ a.name }} FAILING_NOW value={{ a.value }}<=thresh={{ thr(a) }}{{ ' Pre-fail' if a.flags.prefailure | default(false) else ' Old_age' }}]{% endfor %}{% for a in sm.failed_past %} [{{ a.id }} {{ a.name }} In_the_past worst={{ a.worst }}<=thresh={{ thr(a) }}]{% endfor %}
|
||
|
||
{% elif sm.thresholds_present %}
|
||
no attribute at/below its manufacturer threshold{% if sm.prefail_closest %}; closest Pre-fail margin: {{ sm.prefail_closest.id }} {{ sm.prefail_closest.name }} value={{ sm.prefail_closest.value }} vs thresh={{ thr(sm.prefail_closest) }}{% endif %}
|
||
|
||
{% endif %}
|
||
vendor context (NOT used for the verdict — raw units are vendor-defined):
|
||
reallocation events(196)={{ sm.realloc_events if sm.realloc_events is not none else 'n/a' }} grown bad blocks={{ sm.grown_bad_blocks if sm.grown_bad_blocks is not none else 'n/a' }} erase fails={{ sm.erase_fail_count if sm.erase_fail_count is not none else 'n/a' }} program fails={{ sm.program_fail_count if sm.program_fail_count is not none else 'n/a' }} unexpected power loss={{ sm.unexpected_power_loss if sm.unexpected_power_loss is not none else 'n/a' }}
|
||
{% if sm.realloc_attr %}
|
||
attr 5 {{ sm.realloc_attr.name }} raw={{ sm.realloc_attr.raw.string | default(sm.realloc_attr.raw.value) }} (vendor-encoded) vs normalised value={{ sm.realloc_attr.value }} thresh={{ thr(sm.realloc_attr) }} (the signal that is judged)
|
||
{% endif %}
|
||
{% for a in sm.attributes | default([]) %}
|
||
[{{ '%3d' | format(a.id) }}] {{ '%-26s' | format(a.name) }} value={{ a.value }} worst={{ a.worst }} thresh={{ thr(a) }} raw={{ a.raw.string | default(a.raw.value) }}{% if a.when_failed | default('') %} <<< when_failed={{ a.when_failed }}{% endif %}
|
||
|
||
{% endfor %}
|
||
{% else %}
|
||
SMART NOT READ — {{ (node_health_root_skip_reason if not node_health_root_available else ('smartmontools not installed on this node (ships with roles/node_baseline; this role never installs packages)' if not node_health_facts.tools.smartctl else 'device exposes no SMART data')) | regex_replace('\s+', ' ') | trim }}
|
||
{% endif %}
|
||
{% for p in d.partitions %}
|
||
{{ '%-12s' | format(p.kname) }} {{ '%9s' | format(gib(p.size_bytes)) }} {{ '%-6s' | format(p.fstype | default('-', true)) }} {{ (p.mountpoints[:2] | join(', ') ~ (' (+%d more)' | format(p.mountpoints | length - 2) if (p.mountpoints | length) > 2 else '')) if p.mountpoints else '(unmounted)' }}
|
||
{% endfor %}
|
||
|
||
{% endfor %}
|
||
{% if node_health_facts.storage.attached_volumes | default([]) %}
|
||
ATTACHED VOLUMES (iSCSI/FC — Longhorn replicas etc., not hardware in this box)
|
||
{% for a in node_health_facts.storage.attached_volumes %}
|
||
{{ '%-10s' | format(a.path) }} {{ '%9s' | format(gib(a.size_bytes)) }} {{ a.transport }} {{ a.model | default('-', true) }}
|
||
{% endfor %}
|
||
|
||
{% endif %}
|
||
MOUNTED FILESYSTEMS
|
||
{% for m in node_health_facts.storage.mounts | sort(attribute='mountpoint') %}
|
||
{{ '%-14s' | format(m.mountpoint) }} {{ '%-6s' | format(m.fstype) }} {{ '%9s' | format(gib(m.size_bytes)) }} total, {{ '%9s' | format(gib(m.avail_bytes)) }} free ({{ m.used_pct }}% used, inodes {{ (m.inodes_used_pct | string ~ '%') if m.inodes_used_pct is not none else 'n/a' }})
|
||
{% endfor %}
|
||
|
||
--------------------------------------------------------------------------------
|
||
THERMAL
|
||
--------------------------------------------------------------------------------
|
||
{% for z in node_health_facts.thermal.zones %}
|
||
zone {{ '%-18s' | format(z.type | default(z.zone)) }} {{ (z.temp_c | string ~ 'C') if z.temp_c is not none else 'unreadable (sensor returned no data)' }}{% if z.critical_c %} (critical {{ z.critical_c }}C){% endif %}
|
||
|
||
{% endfor %}
|
||
{% for chip in node_health_facts.thermal.hwmon %}
|
||
{% for t in chip.temps %}
|
||
{{ '%-16s' | format(chip.name | default('hwmon')) }} {{ '%-22s' | format(t.label) }} {{ (t.temp_c | string ~ 'C') if t.temp_c is not none else 'unreadable (sensor returned no data)' }}{% if t.crit_c %} (crit {{ t.crit_c }}C){% endif %}
|
||
|
||
{% endfor %}
|
||
{% for f in chip.fans %}
|
||
{{ '%-16s' | format(chip.name | default('hwmon')) }} {{ '%-22s' | format(f.label) }} {{ f.rpm }} rpm
|
||
{% endfor %}
|
||
{% endfor %}
|
||
|
||
--------------------------------------------------------------------------------
|
||
PRESSURE STALL (/proc/pressure — % of wall time stalled)
|
||
--------------------------------------------------------------------------------
|
||
{% for res in ['cpu', 'memory', 'io'] %}
|
||
{% set p = node_health_facts.pressure[res] %}
|
||
{% if p %}
|
||
{{ '%-7s' | format(res) }} some avg10={{ p.some.avg10 }} avg60={{ p.some.avg60 }} avg300={{ p.some.avg300 }}{% if p.full is defined %} | full avg60={{ p.full.avg60 }}{% endif %}
|
||
|
||
{% else %}
|
||
{{ '%-7s' | format(res) }} unavailable
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
--------------------------------------------------------------------------------
|
||
NETWORK
|
||
--------------------------------------------------------------------------------
|
||
{% for n in node_health_facts.network.interfaces %}
|
||
{{ '%-10s' | format(n.name) }} {{ '%-6s' | format(n.operstate) }} {{ '%-8s' | format((n.speed_mbps | string ~ 'Mb/s') if n.speed_mbps else 'no-link') }} {{ '%-9s' | format(n.duplex | default('-')) }} mtu {{ n.mtu }} driver {{ n.driver | default('-') }}
|
||
rx {{ n.statistics.rx_packets | default('?') }} pkts / errors {{ n.statistics.rx_errors | default('?') }} / dropped {{ n.statistics.rx_dropped | default('?') }}
|
||
tx {{ n.statistics.tx_packets | default('?') }} pkts / errors {{ n.statistics.tx_errors | default('?') }} / dropped {{ n.statistics.tx_dropped | default('?') }}
|
||
{% endfor %}
|
||
{% if node_health_facts.network.nameservers is none %}
|
||
nameservers NOT READ — {{ node_health_facts.network.resolver_unavailable_reason | default('the collector recorded no reason', true) | regex_replace('\s+', ' ') | trim | wordwrap(66) | indent(13) }}
|
||
{% else %}
|
||
nameservers {{ node_health_facts.network.nameservers | join(', ') | default('none configured', true) }}
|
||
{% if node_health_facts.network.resolver_stub | default([], true) | length > 0 %}
|
||
(/etc/resolv.conf holds only the systemd-resolved stub {{ node_health_facts.network.resolver_stub | join(', ') }}; the servers above are the real upstreams behind it)
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
--------------------------------------------------------------------------------
|
||
NOT READ (and why) — these are UNKNOWNS, not passes
|
||
--------------------------------------------------------------------------------
|
||
{% for c in node_health_checks if c.status == 'SKIP' %}
|
||
- {{ c.id }}: {{ c.detail | regex_replace('\s+', ' ') | trim | wordwrap(70) | indent(3) }}
|
||
{% else %}
|
||
(nothing skipped)
|
||
{% endfor %}
|
||
{% if node_health_facts.unreadable %}
|
||
|
||
Unreadable paths reported by the collector:
|
||
{% for u in node_health_facts.unreadable %}
|
||
- {{ u.path }} — {{ u.reason }}
|
||
{% endfor %}
|
||
{% endif %}
|
||
|
||
tools present: {% for t, p in node_health_facts.tools.items() %}{{ t }}={{ p | default('MISSING', true) }}{{ ', ' if not loop.last }}{% endfor %}
|
||
|
||
================================================================================
|