From cf33338448c94fb7ac5750830b5dc62408ca4381 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Tue, 1 Sep 2026 22:30:45 -0500 Subject: [PATCH] feat(node_health): detect the EFI memattr bug on every run, two independent ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> 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) --- infra/ansible/roles/node_health/README.md | 42 ++- .../roles/node_health/defaults/main.yml | 27 ++ .../node_health/files/node_health_collect.py | 311 +++++++++++++++++- .../roles/node_health/tasks/assess.yml | 201 +++++++++++ .../node_health/tasks/collect_privileged.yml | 62 ++++ .../ansible/roles/node_health/tasks/main.yml | 18 +- .../node_health/templates/summary.txt.j2 | 9 + 7 files changed, 664 insertions(+), 6 deletions(-) create mode 100644 infra/ansible/roles/node_health/tasks/collect_privileged.yml diff --git a/infra/ansible/roles/node_health/README.md b/infra/ansible/roles/node_health/README.md index a18e949..8b8a6bb 100644 --- a/infra/ansible/roles/node_health/README.md +++ b/infra/ansible/roles/node_health/README.md @@ -32,7 +32,11 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0 - **Works on bare metal and VMs.** Everything absent in a guest (SMART on virtio, DMI, EDAC, `coretemp`, CPU throttle counters) skips cleanly instead of erroring. Verified against both `k3s-prod-1` (HP EliteDesk, bare metal) and `k3s-dev-1` (Proxmox VM). -- **Never `become`s unless it must.** Only SMART needs root. +- **Never `become`s unless it must.** Three things need root: SMART, the kernel ring + buffer (`dmesg`, because Debian sets `kernel.dmesg_restrict=1`) and the SMBIOS DIMM + inventory (`dmidecode -t 17`). Everything else is read unprivileged in one pass, and + the privileged second pass re-runs the *same* collector with `--privileged` so the + parsing has one home. Without escalation those checks SKIP with the exact reason. ## What it checks @@ -44,15 +48,51 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0 | `storage.capacity`, `storage.inodes` | worst mounted filesystem, space and inodes | | `storage.layout` | physical disks, and whether any device is genuinely free for `node_baseline_longhorn_device` / `node_baseline_etcd_device` | | `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite, EDAC correctable/uncorrectable counters | +| `memory.physical_total` | **is this machine using the RAM it has?** `/proc/zoneinfo` present-vs-managed pages (no root, metal or VM) and `MemTotal` vs the DIMM capacity SMBIOS reports (`dmidecode -t 17`, root, bare metal). Cause-independent by design — see [The EFI memattr check](#the-efi-memattr-check) | | `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) | | `thermal.max` | hottest sensor across thermal zones and hwmon chips | | `pressure.cpu` / `.memory` / `.io` | PSI `some avg60` — dev learned to care about CPU PSI specifically | | `network.link`, `network.errors`, `network.resolvers` | link state/speed, NIC error counters, DNS upstream redundancy per address family | | `os.reboot_required`, `os.inventory`, `firmware.inventory` | pending reboot, OS/kernel/uptime, chassis + BIOS | +| `firmware.efi_memattr` | the kernel-log signature of the corrupt EFI Memory Attributes Table that costs a node most of its RAM — **root only**. See [The EFI memattr check](#the-efi-memattr-check) | Thresholds live in `defaults/main.yml` (`node_health_thresholds`); no verdict is hardcoded in task logic. + +## The EFI memattr check + +`infra/PROD-PROVISIONING.md` §0b is authoritative; this is only what the check needs. + +HP's 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 * desc_size`) and `memblock_reserve()`s the product, i.e. everything from the +table address to the top of RAM. Which of two kernel-log lines appears decides whether the +node keeps its memory, and it is **decided afresh on every boot**: + +| kernel log | meaning | verdict | +|---|---|---| +| `efi: memattr: Failed to map EFI Memory Attributes table @ 0x…` | the garbage `version` field read `<= 2`, the kernel trusted the table | **FAIL** — the node has lost the memory *now* | +| `efi: memattr: Unexpected EFI Memory Attributes table version ` | the garbage `version` read `> 2`, the kernel bailed out first | **WARN** — memory intact *this boot* only; the firmware is still publishing a corrupt table | +| `[Firmware Bug]: Corrupted EFI Memory Attributes Table detected!` | kernel >= 6.13 validated and rejected it | PASS — this *is* the fix, working | +| no memattr line, log reaches back to boot | no table published (VM/OVMF), or a valid one | PASS | +| no memattr line, log does **not** reach back to boot | the ring buffer wrapped past early boot | **SKIP** — unknown, not unaffected | +| `dmesg` unreadable | no root / `dmesg_restrict=1` | **SKIP** — unknown, not unaffected | + +The WARN is the point. `k3s-prod-1` and `k3s-prod-2` publish the same corrupt table and +differ only by **luck**: `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. + +**Fix:** boot a kernel **>= 6.13** (Debian 13: `linux-image-amd64` from +`trixie-backports`); upstream commit `8fbe4c49c0cc`. There is **no kernel cmdline +workaround** — `efi_memattr_init()` runs before any `efi=` bail-out could take effect, and +rebooting/CMOS/BIOS/USB changes only move where the table lands. + +`memory.physical_total` measures the same *symptom* from `/proc/zoneinfo` alone, so a node +whose kernel log cannot be read is still not unmeasured — and a future memory loss with a +different cause is still caught. + ## Output 1. A human summary printed in the play output. diff --git a/infra/ansible/roles/node_health/defaults/main.yml b/infra/ansible/roles/node_health/defaults/main.yml index 7a2fc72..eae98c9 100644 --- a/infra/ansible/roles/node_health/defaults/main.yml +++ b/infra/ansible/roles/node_health/defaults/main.yml @@ -45,6 +45,33 @@ node_health_thresholds: # Memory (percentage of MemTotal still available) mem_available_pct_warn: 15 mem_available_pct_fail: 5 + # --- MEMORY THE MACHINE HAS THAT THE OS WILL NEVER USE ---------------------------- + # Two independent measurements of the same question, both fed by memory.physical_total: + # + # unmanaged /proc/zoneinfo present pages MINUS managed pages — RAM the kernel's own + # memory map says exists, that never reached the allocator. Needs no root + # and no tools, works identically on metal and in a VM. + # shortfall MemTotal against the DIMM capacity SMBIOS (dmidecode -t 17) says is + # installed. Needs root, and only bare metal reports it. Independent of the + # kernel's memory map entirely, so it also catches RAM the firmware never + # handed the kernel in the first place. + # + # MEASURED BASELINES for the unmanaged figure, so these are tuned against numbers and + # not taste: 2.0% k3s-prod-1 (32 GiB metal), 2.1% (32 GiB Fedora workstation), 2.2% + # k3s-dev-1 (24 GiB VM) — versus 83.4% on k3s-prod-2 in the EFI-memattr failure state. + # The healthy figure is the kernel image, memmap and firmware reservations; it is a + # near-constant few hundred MB, so a small machine sits higher in PERCENT terms and 10% + # keeps clearance for that. There is no plausible tuning of a threshold in this band + # that changes any of those five verdicts. + # + # The DIMM shortfall runs a little higher than the unmanaged figure on the same node + # (2.7% on k3s-prod-1) because installed capacity also covers e820-reserved regions and + # the 64 MiB of graphics stolen memory — legitimately missing RAM that was never the + # kernel's to manage. Same bands: the signal being caught is 83%, not 3%. + mem_unmanaged_pct_warn: 10 + mem_unmanaged_pct_fail: 25 + mem_dimm_shortfall_pct_warn: 10 + mem_dimm_shortfall_pct_fail: 25 # Temperatures (deg C, any sensor) temp_warn_c: 70 temp_fail_c: 85 diff --git a/infra/ansible/roles/node_health/files/node_health_collect.py b/infra/ansible/roles/node_health/files/node_health_collect.py index 37adf25..9f2c62b 100644 --- a/infra/ansible/roles/node_health/files/node_health_collect.py +++ b/infra/ansible/roles/node_health/files/node_health_collect.py @@ -97,10 +97,22 @@ def read_kv(path, sep=":"): return out +def which(tool): + """Locate a binary, including the sbin dirs a NON-ROOT PATH usually omits. + + `dmidecode` lives in /usr/sbin and is simply invisible to `shutil.which()` under the + unprivileged `k3s` user's PATH. Reporting "not installed" for a tool that IS installed + but unreachable would be a false reason on a SKIP, which is the one thing this + collector may never do.""" + return shutil.which(tool) or shutil.which(tool, path="/usr/sbin:/sbin:/usr/bin:/bin") + + def run(argv, timeout=20): """Run a read-only query command. Returns (rc, stdout, stderr); rc None if absent.""" - if shutil.which(argv[0]) is None: + exe = which(argv[0]) + if exe is None: return None, "", "%s not installed" % argv[0] + argv = [exe] + list(argv[1:]) try: proc = subprocess.run( argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout @@ -284,9 +296,285 @@ def collect_memory(): edac["ce_total"] = ce_total edac["ue_total"] = ue_total mem["edac"] = edac + # Physically present vs actually usable — the cause-independent memory-loss signal. + mem["zones"] = collect_zoneinfo(mem["mem_total_kb"]) + # Firmware's own inventory of what is physically installed. Root-only; attempted + # here anyway so the unprivileged pass records the exact reason it could not read. + mem["devices"] = collect_memory_devices() return mem +# --- Memory the kernel has but never hands out -------------------------------------- +# /proc/zoneinfo is WORLD-READABLE on every Linux, VM or metal — no root, no dmidecode, +# no DMI. Per zone it reports: +# spanned pages in the zone's address range, holes included +# present spanned MINUS the holes — i.e. RAM the kernel's e820/EFI memory map says +# physically exists here +# managed present MINUS everything memblock reserved before the buddy allocator was +# handed the rest (kernel image, memmap, initrd, crashkernel, firmware +# reservations). sum(managed) IS MemTotal, exactly, on every node measured. +# So `present - managed` is, by construction, "RAM this machine has that the OS will +# never allocate", whatever the reason. On a healthy node it is the couple of hundred +# MB the kernel legitimately keeps: measured 2.0% (k3s-prod-1), 2.2% (k3s-dev-1) and +# 2.7% (a 24 GiB VM). On k3s-prod-2 in the EFI-memattr failure state it is 83.4% — +# `Normal` zone present 3340288 / managed 0. That is a three-order-of-magnitude +# separation, which is why this needs no clever tuning to be trustworthy. +# +# It is deliberately NOT a dmesg-wording check: it would still catch the same symptom if +# a future firmware, kernel or hypervisor lost the memory for an entirely different +# reason, and it needs neither root nor a parseable kernel log. +def collect_zoneinfo(mem_total_kb): + raw = read_text("/proc/zoneinfo", optional=False) + page_kb = os.sysconf("SC_PAGE_SIZE") // 1024 + out = { + "available": raw is not None, + "reason": None if raw is not None else "/proc/zoneinfo could not be read", + "page_size_kb": page_kb, + "zones": [], + "present_kb": None, + "managed_kb": None, + "unmanaged_kb": None, + "unmanaged_pct": None, + } + if raw is None: + return out + node = zone = None + cur = {} + present_pages = managed_pages = 0 + for line in raw.splitlines(): + fields = line.split() + if line.startswith("Node ") and "zone" in line: + # "Node 0, zone Normal" + if cur: + out["zones"].append(cur) + node, zone = fields[1].rstrip(","), fields[-1] + cur = {"node": node, "zone": zone, "spanned": None, "present": None, "managed": None} + elif cur and len(fields) == 2 and fields[0] in ("spanned", "present", "managed"): + try: + cur[fields[0]] = int(fields[1]) + except ValueError: + cur[fields[0]] = None + if cur: + out["zones"].append(cur) + # An empty parse is a PARSE failure, not "this node has no memory" — say so and stay + # null rather than emitting a confident 0. + if not out["zones"]: + out["available"] = False + out["reason"] = "/proc/zoneinfo was read but no zone could be parsed from it" + return out + for z in out["zones"]: + present_pages += z["present"] or 0 + managed_pages += z["managed"] or 0 + out["present_kb"] = present_pages * page_kb + out["managed_kb"] = managed_pages * page_kb + out["unmanaged_kb"] = out["present_kb"] - out["managed_kb"] + if out["present_kb"] > 0: + out["unmanaged_pct"] = round(out["unmanaged_kb"] * 100.0 / out["present_kb"], 1) + # Cross-check for the report: sum(managed) should equal MemTotal. If it does not, the + # two readings disagree and the discrepancy itself is worth showing. + out["matches_mem_total"] = ( + None if (mem_total_kb is None or out["managed_kb"] is None) + else abs(out["managed_kb"] - mem_total_kb) <= page_kb + ) + return out + + +# --- Installed DIMMs (SMBIOS type 17 — ROOT ONLY) ----------------------------------- +# The independent answer to "how much RAM is physically in this box", from the firmware's +# own inventory rather than from anything the kernel decided. Needs root: dmidecode reads +# /sys/firmware/dmi/tables/DMI (mode 0400) or /dev/mem. Unprivileged it fails, and that +# failure is recorded as the SKIP reason — never as zero DIMMs. +# +# On a VM there is usually either no type-17 structure at all or a single synthetic entry +# that merely restates the configured guest RAM; both are handled by reporting what was +# found and letting the check SKIP when nothing populated was reported. +DMI_SIZE_UNITS = {"kb": 1, "mb": 1024, "gb": 1024 * 1024, "tb": 1024 * 1024 * 1024} + + +def collect_memory_devices(): + out = { + "available": False, + "reason": None, + "devices": [], + "slots": 0, + "populated_slots": 0, + "installed_kb": None, + } + if which("dmidecode") is None: + out["reason"] = ("dmidecode is not installed on this node; it ships with " + "roles/node_baseline (node_baseline_packages). " + "This role never installs packages.") + return out + rc, stdout, stderr = run(["dmidecode", "-t", "17"], timeout=30) + if rc != 0: + out["reason"] = "dmidecode -t 17 failed (rc=%s): %s" % ( + rc, (stderr or stdout).strip().splitlines()[-1] if (stderr or stdout).strip() else "no output") + return out + # Blocks are separated by blank lines; each "Memory Device" block is one SLOT, which + # may or may not hold a module ("Size: No Module Installed"). + total_kb = 0 + saw_size = False + for block in stdout.split("\n\n"): + if "Memory Device" not in block: + continue + fields = {} + for line in block.splitlines(): + key, sep, value = line.partition(":") + if sep: + fields[key.strip()] = value.strip() + if "Size" not in fields: + continue + out["slots"] += 1 + size_kb = None + parts = fields["Size"].split() + if len(parts) == 2 and parts[1].lower() in DMI_SIZE_UNITS: + try: + size_kb = int(parts[0]) * DMI_SIZE_UNITS[parts[1].lower()] + except ValueError: + size_kb = None + if size_kb: + saw_size = True + total_kb += size_kb + out["populated_slots"] += 1 + out["devices"].append( + { + "locator": fields.get("Locator"), + "bank_locator": fields.get("Bank Locator"), + # Verbatim, so an unparsed spelling is visible rather than silently null. + "size_raw": fields["Size"], + "size_kb": size_kb, + "type": fields.get("Type"), + "speed": fields.get("Speed"), + "configured_speed": fields.get("Configured Memory Speed"), + "manufacturer": fields.get("Manufacturer"), + "part_number": fields.get("Part Number"), + "rank": fields.get("Rank"), + # Serial deliberately NOT collected: the artifact already carries drive + # serials and a DIMM serial adds identifying data no check reads. + } + ) + out["available"] = saw_size + if not saw_size: + out["reason"] = ("dmidecode ran but reported no populated memory device " + "(%d slot structure(s) found) — typical of a VM with no SMBIOS " + "type 17 inventory" % out["slots"]) + else: + out["installed_kb"] = total_kb + return out + + +# --- Kernel ring buffer: the EFI Memory Attributes Table ---------------------------- +# WHY THIS IS HERE. See infra/PROD-PROVISIONING.md Section 0b for the authoritative +# write-up. Short version: HP's 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 unvalidated fields and memblock_reserve()s +# the product). The kernel prints exactly one of two lines, and WHICH one decides whether +# the node keeps its RAM: +# +# "Failed to map EFI Memory Attributes table @ 0x..." the table's garbage version read +# <= 2, the kernel trusted it and reserved gigabytes. THE NODE HAS LOST MEMORY NOW. +# "Unexpected EFI Memory Attributes table version " the garbage version read > 2, so +# the kernel bailed out before reserving anything. Memory intact THIS BOOT — but the +# firmware is still publishing a corrupt table and the outcome is decided afresh by +# where the table lands on every boot. That is luck, not immunity. +# +# It is boot-to-boot NON-DETERMINISTIC (k3s-prod-2 booted with its full 15.9 GiB two boots +# before it lost 13.3 GiB), which is precisely why it is detected on every health run +# instead of diagnosed once. +# +# dmesg needs root when kernel.dmesg_restrict=1 (Debian's default, and the setting on +# every node here). The unprivileged pass therefore records WHY it could not read, and +# roles/node_health re-runs this collector with `--privileged` when escalation works. +BOOT_ANCHOR_RE = re.compile(r"Linux version \S|BIOS-provided physical RAM map|Command line:") +EFI_CONFIG_RE = re.compile(r"\befi:\s.*\bMEMATTR=(0x[0-9a-fA-F]+)") +MEMATTR_FAILED_RE = re.compile( + r"Failed to map EFI Memory Attributes table\s*@?\s*(0x[0-9a-fA-F]+)?", re.I) +MEMATTR_VERSION_RE = re.compile( + r"Unexpected EFI Memory Attributes table version\s+(\d+)", re.I) +MEMATTR_CORRUPT_RE = re.compile(r"Corrupted EFI Memory Attributes Table", re.I) +# The DIAGNOSTIC lines only. Deliberately NOT a bare "memattr": the EFI config-table +# line lists "MEMATTR=0x..." on every UEFI boot and is not a finding — its address is +# captured separately, above, and quoting it as evidence would bury the one line that +# matters in 200 characters of unrelated table addresses. +MEMATTR_LINE_RE = re.compile(r"memattr:|EFI Memory Attributes", re.I) +# "Memory: 2568544K/16546088K available (... 13958980K reserved, ...)" — the same line +# Section 0b tabulates per boot. Collected as corroborating evidence, not as a verdict. +MEMORY_LINE_RE = re.compile(r"Memory:\s+(\d+)K/(\d+)K available") +MEMORY_RESERVED_RE = re.compile(r"(\d+)K reserved") + + +def collect_klog(): + out = { + "available": False, + "reason": None, + "boot_covered": None, + "memattr_state": None, + "memattr_published": None, + "memattr_table_address": None, + "memattr_version": None, + "memattr_lines": [], + "memory_line": None, + } + rc, stdout, stderr = run(["dmesg"], timeout=30) + if rc is None: + out["reason"] = "dmesg is not installed on this node" + return out + if rc != 0: + detail = (stderr or stdout).strip().splitlines() + out["reason"] = "dmesg failed (rc=%d, uid=%d): %s%s" % ( + rc, os.geteuid(), detail[-1] if detail else "no output", + " — kernel.dmesg_restrict=1, so reading the ring buffer needs root/CAP_SYSLOG" + if read_int("/proc/sys/kernel/dmesg_restrict") == 1 else "") + return out + + out["available"] = True + # Does the buffer still reach back to early boot? The ring buffer WRAPS, and every + # memattr line is printed in the first fraction of a second. If the boot header is + # gone then so may be a memattr line that was printed — absence would prove nothing, + # and the check must SKIP rather than PASS. This is the difference between + # "unaffected" and "could not tell", and the role is not allowed to blur it. + out["boot_covered"] = bool(BOOT_ANCHOR_RE.search(stdout)) + + published = EFI_CONFIG_RE.search(stdout) + if published: + out["memattr_published"] = True + out["memattr_table_address"] = published.group(1) + elif re.search(r"^\[[^]]*\]\s*efi:\s", stdout, re.M): + # UEFI booted and the config-table line is present, but it lists no MEMATTR=. + out["memattr_published"] = False + # else: no efi: line at all (BIOS/CSM boot, or the line has scrolled away) -> None. + + for line in stdout.splitlines(): + if MEMATTR_LINE_RE.search(line): + out["memattr_lines"].append(line.strip()) + joined = "\n".join(out["memattr_lines"]) + if MEMATTR_CORRUPT_RE.search(joined): + # Kernel >= 6.13 (commit 8fbe4c49c0cc): the table is validated and rejected. + out["memattr_state"] = "corrupt_rejected" + elif MEMATTR_FAILED_RE.search(joined): + out["memattr_state"] = "failed_to_map" + addr = MEMATTR_FAILED_RE.search(joined).group(1) + if addr: + out["memattr_table_address"] = addr + elif MEMATTR_VERSION_RE.search(joined): + out["memattr_state"] = "bad_version" + out["memattr_version"] = int(MEMATTR_VERSION_RE.search(joined).group(1)) + else: + out["memattr_state"] = "clean" + + mem_line = MEMORY_LINE_RE.search(stdout) + if mem_line: + full = [l for l in stdout.splitlines() if mem_line.group(0) in l] + reserved = MEMORY_RESERVED_RE.search(full[0]) if full else None + out["memory_line"] = { + "available_kb": int(mem_line.group(1)), + "total_kb": int(mem_line.group(2)), + "reserved_kb": int(reserved.group(1)) if reserved else None, + "raw": full[0].strip() if full else None, + } + return out + + # --- Thermals ----------------------------------------------------------------------- def collect_thermal(): zones = [] @@ -708,15 +996,34 @@ def collect_os(): def collect_tools(): tools = {} for tool in ("smartctl", "nvme", "ethtool", "dmidecode", "lsblk", "lscpu"): - tools[tool] = shutil.which(tool) or shutil.which(tool, path="/usr/sbin:/sbin:/usr/bin:/bin") + tools[tool] = which(tool) return tools def main(): + # --privileged: re-collect ONLY the fields that genuinely need root, for the second + # pass roles/node_health makes when (and only when) escalation is proven to work + # (tasks/collect_privileged.yml). Parsing therefore lives in exactly one place — + # re-implementing the kernel-log grep in Jinja would be a second source of truth for + # the same fact, and the two would drift. + if "--privileged" in sys.argv[1:]: + doc = { + "schema": SCHEMA, + "collected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "collector_uid": os.geteuid(), + "klog": collect_klog(), + "memory": {"devices": collect_memory_devices()}, + } + doc["unreadable"] = UNREADABLE + json.dump(doc, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return + doc = { "schema": SCHEMA, "collected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "collector_uid": os.geteuid(), + "klog": collect_klog(), "os": collect_os(), "dmi": collect_dmi(), "cpu": collect_cpu(), diff --git a/infra/ansible/roles/node_health/tasks/assess.yml b/infra/ansible/roles/node_health/tasks/assess.yml index 3023397..7553959 100644 --- a/infra/ansible/roles/node_health/tasks/assess.yml +++ b/infra/ansible/roles/node_health/tasks/assess.yml @@ -387,6 +387,83 @@ ~ (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 + [{ @@ -674,3 +751,127 @@ ~ (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 " 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 -%} diff --git a/infra/ansible/roles/node_health/tasks/collect_privileged.yml b/infra/ansible/roles/node_health/tasks/collect_privileged.yml new file mode 100644 index 0000000..5afb552 --- /dev/null +++ b/infra/ansible/roles/node_health/tasks/collect_privileged.yml @@ -0,0 +1,62 @@ +--- +# The SECOND pass of the collector, run with escalation, for the two facts that are +# genuinely root-gated: +# +# * the KERNEL RING BUFFER (`dmesg`) — Debian sets kernel.dmesg_restrict=1, so an +# unprivileged read gets EPERM. This is where the EFI Memory Attributes Table +# diagnostic lives (infra/PROD-PROVISIONING.md §0b), and that line is the difference +# between a node that has lost 13.3 GiB and one that has not. +# * SMBIOS type 17 (`dmidecode -t 17`) — the firmware's own inventory of installed +# DIMMs, read from /sys/firmware/dmi/tables/DMI (mode 0400) or /dev/mem. +# +# WHY RE-RUN THE COLLECTOR RATHER THAN SHELL OUT HERE. Both facts need real parsing (a +# kernel-log grep with three alternative messages, a boot-coverage anchor, a blank-line- +# delimited SMBIOS block parse). Re-implementing that in Jinja would create a SECOND +# source of truth for the same fact, and the two would drift. `--privileged` makes the +# collector emit exactly those subtrees and nothing else, so the parsing stays in one +# place and is identical whichever pass produced it. +# +# REACHED ONLY when probe_privilege.yml proved escalation works AND the unprivileged pass +# actually failed to read one of them (main.yml checks both). On a node where +# dmesg_restrict=0 the first pass already succeeded and this never runs — the role's rule +# is that everything readable WITHOUT root is read without root. +# +# If escalation is unavailable the checks that consume these SKIP with the exact reason +# the unprivileged attempt recorded. They never silently become a PASS: "no memattr line" +# and "could not read the kernel log" are different answers and the role says which. + +- name: Re-collect the root-only facts with escalation + ansible.builtin.script: + cmd: node_health_collect.py --privileged + executable: /usr/bin/python3 + become: true + vars: + ansible_become: true + register: node_health_privileged_raw + changed_when: false # strictly read-only; it can never change the node + +- name: Assert the privileged payload is the schema this role understands + ansible.builtin.assert: + that: + - (node_health_privileged_raw.stdout | from_json).schema == 'node-health/1' + fail_msg: >- + Privileged collector pass returned an unexpected schema; role and collector are + out of step. + quiet: true + vars: + ansible_become: false + +# Merge, field by field, over the unprivileged facts. Explicit rather than a recursive +# `combine` because `unreadable` is a LIST and must be APPENDED — a recursive combine +# would REPLACE it and throw away the reasons the first pass recorded. +- name: Merge the privileged facts over the unprivileged ones + ansible.builtin.set_fact: + node_health_facts: >- + {{ node_health_facts | combine({ + 'klog': priv.klog, + 'memory': node_health_facts.memory | combine({'devices': priv.memory.devices}), + 'unreadable': (node_health_facts.unreadable | default([])) + (priv.unreadable | default([])), + }) }} + vars: + ansible_become: false + priv: "{{ node_health_privileged_raw.stdout | from_json }}" diff --git a/infra/ansible/roles/node_health/tasks/main.yml b/infra/ansible/roles/node_health/tasks/main.yml index a57ffc0..2a0d07c 100644 --- a/infra/ansible/roles/node_health/tasks/main.yml +++ b/infra/ansible/roles/node_health/tasks/main.yml @@ -4,9 +4,11 @@ # ORDER OF OPERATIONS # 1. Collect everything readable WITHOUT root (one script, one round trip). # 2. Probe whether privilege escalation actually works on this host. -# 3. Only if it does AND smartmontools is installed: read SMART. -# 4. Turn facts into a list of explicit PASS/WARN/FAIL/SKIP checks. -# 5. Render the human summary + write the JSON artifact. +# 3. Only if it does, and only for what the first pass could NOT read: re-run the same +# collector with --privileged (kernel ring buffer, SMBIOS DIMM inventory). +# 4. Only if it does AND smartmontools is installed: read SMART. +# 5. Turn facts into a list of explicit PASS/WARN/FAIL/SKIP checks. +# 6. Render the human summary + write the JSON artifact. # # BECOME HANDLING — the subtle bit, read before editing. # `ansible_become` set as an inventory/group var (which is exactly what @@ -43,6 +45,16 @@ ansible.builtin.include_tasks: probe_privilege.yml when: node_health_try_privileged | bool +# Only when the unprivileged pass actually failed to read one of them. On a node with +# kernel.dmesg_restrict=0 the first pass already has the kernel log and this is skipped +# entirely — "everything readable WITHOUT root is read without root" (defaults/main.yml). +- name: Re-read the root-only facts with escalation (kernel log, SMBIOS DIMMs) + ansible.builtin.include_tasks: collect_privileged.yml + when: + - node_health_root_available | bool + - (not (node_health_facts.klog.available | bool)) + or (not (node_health_facts.memory.devices.available | bool)) + - name: Read SMART data (privileged) ansible.builtin.include_tasks: collect_smart.yml when: diff --git a/infra/ansible/roles/node_health/templates/summary.txt.j2 b/infra/ansible/roles/node_health/templates/summary.txt.j2 index 23a32ba..732af45 100644 --- a/infra/ansible/roles/node_health/templates/summary.txt.j2 +++ b/infra/ansible/roles/node_health/templates/summary.txt.j2 @@ -45,6 +45,15 @@ {% 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(' ') }} --------------------------------------------------------------------------------