Files
home-network-k3s-impl/infra/ansible/roles/memattr_survey/tasks/preflight.yml
T
jakman85andClaude Opus 5 8e913fdd7b fix(node_health): "Failed to map" does NOT mean memory lost — plus survey results
THE MODEL WAS WRONG AND MY CHECK WITH IT. A live counter-example: k3s-prod-2 after its power
cycle shows MEMATTR=0xbeeb0018 and 'Failed to map ... @ 0xbeeb0018' — the SAME address and SAME
line as both of its 2.6 GiB boots — with MemTotal 15857732 kB, i.e. 15.1 GiB of 16 GB. FULL
MEMORY. The check reported FAIL 'THIS NODE HAS LOST MEMORY TO THE BUG' on a healthy node while
memory.physical_total correctly PASSed at 4.2%. The two checks contradicted each other.

REFINED MECHANISM — two kernel functions were conflated:
  efi_memattr_init() does the memblock_reserve(). Catastrophic loss needs tbl_size NEGATIVE
    (signed int -> sign-extended -> clamped to PHYS_ADDR_MAX). A garbage-but-POSITIVE tbl_size
    reserves a modest wrong amount and costs nothing noticeable.
  efi_memattr_apply_permissions() does the later memremap(), and THAT is what prints
    'Failed to map'. It fails for ANY sufficiently wrong size, negative or merely large-positive.
So the line proves the table was corrupt AND TRUSTED, not that memory was lost. The cost is set
by the garbage VALUE, which is why one address gave 2.6 GiB twice and 15.1 GiB once.

THIS ALSO REFUTES THE ADDRESS->OUTCOME HYPOTHESIS OUTRIGHT — not untested, disproven.

STRUCTURAL FIX so the contradiction cannot recur: the shortfall is computed ONCE into a shared
fact (node_health_mem_shortfall) with worst-wins sub-verdicts; memory.physical_total IS that
verdict and firmware.efi_memattr READS it, measuring nothing itself. FAIL is gated on the shared
verdict. 'Failed to map' + normal memory is now WARN ('the bug fired and this boot survived it').
No honesty guard was weakened; one was strengthened (the measurement clause falls back to the DIMM
shortfall when zoneinfo is unreadable, and only claims 'could NOT be measured' when neither was
read). All five paths incl. the two unproducible-live SKIPs exercised against synthetic facts.
Verdicts: prod-1 WARN, prod-2 WARN (was FAIL), prod-3 PASS, dev unaffected.

SECOND FAULT, arguably worse for prod: NODE 2 HANGS ON A WARM REBOOT, STAYING POWERED. Its
journal ends cleanly mid-shutdown ('eno1: NIC Link is Down') then stops; the box sat powered but
off-LAN for ~38 min until the operator held the power button. A node that cannot survive an
unattended warm reboot cannot be rolled in an HA cluster, and node_health cannot see a machine
that is off the network. Leading candidate: node 2 is the ONLY node with 'Recover OS after Boot
Failure = Enable' — promoted from 'coincidence' in the BIOS diff to a live availability defect.
The reboot survey is NOT viable unattended on node 2 until this is understood.

SURVEY RESULTS (40 boots): prod-1 0/20, prod-3 0/20, prod-2 0 ROUNDS (hung on its first reboot),
pooled 0/40 -> p(bad) 95% CI 0.000-0.088. NOT elimination: 0/20 still permits 17% per node, and
node 2's prior 2-of-3 is unchanged. What it did establish: addresses are node-specific and highly
concentrated (node 1 used 3 distinct addresses in 20 boots, 15 at one; no address ever shared
between nodes); MEMATTR -> parse was a PERFECT function across all 7 repeated addresses over 37
boots; and the corrupting bytes are stable PER MACHINE (node 1 always 1030, node 3 always
1463964749), so the 'shared OS boot chain' inference needs refining.

Adds playbooks/memattr-survey.yml + roles/memattr_survey — reboots opt-in, resumable, artifacts
gitignored. Documents an Ansible defect worth knowing repo-wide: a looped include_tasks under the
default 'linear' strategy is flattened per host, so hosts run SERIALLY; the first attempt rebooted
node 1 twenty times while the others idled. Fixed with strategy: free + a forks preflight assert.

Serves: R-ENV-01, R-ENV-03, R-OBS-04, R-TEST-02. Task: T-7.1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 23:50:59 -05:00

150 lines
7.3 KiB
YAML

---
# Safety gate. Runs with any_errors_fatal at the play level (playbooks/memattr-survey.yml),
# so a violation on ANY node stops the entire playbook before a single reboot — the
# correct posture when the failure mode is "rebooted a machine that was doing something".
#
# VERIFY, DO NOT ASSUME. The premise that makes these reboots safe is that prod carries no
# k3s and runs nothing. That premise is checked here on every run rather than trusted from
# a document, because the document and the machine drift independently.
- name: Assert the target host is in the prod group
# The dev cluster is LIVE. This is the one check that a mistyped --limit or a copied
# command line cannot slip past: membership is read from the inventory, not the pattern.
ansible.builtin.assert:
that:
- memattr_survey_required_group in (group_names | default([]))
fail_msg: >-
REFUSING TO SURVEY {{ inventory_hostname }}: it is not a member of
'{{ memattr_survey_required_group }}' (groups: {{ group_names | default([]) | join(', ') }}).
This role reboots its targets. Point it at prod, or override
memattr_survey_required_group deliberately.
quiet: true
- name: Check whether any cluster or container runtime unit is active
# `is-active` returns non-zero for inactive/unknown units, which is the expected answer
# here, so the rc is data and not a failure.
ansible.builtin.command:
argv: "{{ ['systemctl', 'is-active'] + memattr_survey_forbidden_units }}"
register: memattr_survey_units
changed_when: false
failed_when: false
- name: Check whether k3s has been installed on this node
ansible.builtin.stat:
path: "{{ item }}"
loop: "{{ memattr_survey_forbidden_paths }}"
register: memattr_survey_paths
changed_when: false
- name: Assert nothing is running on this node
ansible.builtin.assert:
that:
- memattr_survey_units.stdout_lines | reject('equalto', 'inactive') | reject('equalto', 'unknown') | list | length == 0
- memattr_survey_paths.results | selectattr('stat.exists') | list | length == 0
fail_msg: >-
REFUSING TO REBOOT {{ inventory_hostname }} — it is not idle.
units {{ memattr_survey_forbidden_units | zip(memattr_survey_units.stdout_lines) | map('join', '=') | join(' ') }};
present paths: {{ memattr_survey_paths.results | selectattr('stat.exists') | map(attribute='item') | list | join(', ') | default('none', true) }}.
The survey is only sanctioned because these boxes carry no k3s and run nothing.
quiet: true
- name: Clear any abort marker from a previous run
# A marker left by a run that has since been resumed successfully would make this run's
# report describe the LAST failure rather than this one. The marker means "this run
# aborted", so it is cleared at the start of every run and re-written only if it does.
ansible.builtin.file:
path: "{{ memattr_survey_artifact_dir }}/{{ inventory_hostname }}.aborted.json"
state: absent
delegate_to: localhost
become: false
changed_when: false
when: memattr_survey_write_artifacts | bool
- name: Assert forks allows every node to be surveyed concurrently
# The survey play runs `strategy: free` so the nodes reboot in parallel, but forks is a
# hard cap on that: with fewer forks than hosts the survey quietly serialises and takes
# a multiple of the wall clock it should. Cheap to satisfy (`-f <n>`), so assert rather
# than discover it three hours later.
ansible.builtin.assert:
that:
- (lookup('ansible.builtin.config', 'DEFAULT_FORKS') | int) >= (ansible_play_hosts_all | length)
fail_msg: >-
forks={{ lookup('ansible.builtin.config', 'DEFAULT_FORKS') }} but this survey targets
{{ ansible_play_hosts_all | length }} hosts. The rounds would serialise. Re-run with
-f {{ ansible_play_hosts_all | length }} (or higher).
quiet: true
run_once: true
- name: Read the node's resume state and baseline boot
# One call does double duty: it reports how far a previous run got (so this one resumes)
# and, with --round 0, records the boot we found the node on. Round 0 is a free data
# point — it is a boot nobody chose, so it is not selected for by the survey.
ansible.builtin.script:
cmd: >-
memattr_probe.py --status {{ memattr_survey_state_file | quote }}
executable: /usr/bin/python3
register: memattr_survey_status_raw
changed_when: false
- name: Record the resume point
ansible.builtin.set_fact:
memattr_survey_status: "{{ memattr_survey_status_raw.stdout | from_json }}"
memattr_survey_aborted: false
memattr_survey_abort_reason: ""
- name: Assert the probe schema is the one this role understands
ansible.builtin.assert:
that:
- memattr_survey_status.schema == 'memattr-survey/1'
fail_msg: >-
Probe returned schema {{ memattr_survey_status.schema | default('<none>') }};
this role understands memattr-survey/1. Role and probe are out of step.
quiet: true
- name: Probe the boot the node is currently on
# On a FIRST run this is stamped round 0 and appended: the boot the node was already
# sitting on is a free, unselected data point. On a RESUME it is read but NOT appended,
# because that boot is already round N of the interrupted run and double-counting it
# would bias the sample toward whatever outcome happened to survive the interruption.
ansible.builtin.script:
cmd: >-
memattr_probe.py
{% if memattr_survey_status.completed_rounds | int == 0 %}
--round 0 --append {{ memattr_survey_state_file | quote }}
{% endif %}
executable: /usr/bin/python3
register: memattr_survey_baseline_raw
# The append is the experiment's own ledger, not a change to the node under test: this
# role reports `changed` only for things that could affect a boot, and a file written
# under /var/lib long after the kernel parsed the table cannot.
changed_when: false
- name: Parse the baseline record
ansible.builtin.set_fact:
memattr_survey_baseline: "{{ memattr_survey_baseline_raw.stdout | from_json }}"
- name: Assert the journal is PERSISTENT
# The end-of-run harvest is the authoritative record precisely because it re-derives
# every boot from the journal. On a volatile journal (/run/log/journal only) each
# reboot erases the evidence and the harvest would silently return one boot — a survey
# that looks complete and holds nothing. Verified, not assumed.
ansible.builtin.assert:
that:
- memattr_survey_baseline.journal_storage.persistent | bool
fail_msg: >-
{{ inventory_hostname }} has NO persistent journal (/var/log/journal missing).
Every reboot would erase the record this survey depends on. Refusing to run.
quiet: true
- name: Report the starting state
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: idle, prod, persistent journal.
kernel {{ memattr_survey_baseline.kernel_release | default('?') }},
installed {{ (memattr_survey_baseline.smbios_installed_kb | default(0) | int / 1048576) | round(1) }} GiB.
baseline boot MEMATTR={{ memattr_survey_baseline.memattr_address | default('none') }}
kernel_read={{ memattr_survey_baseline.kernel_read | default('?') }}
verdict={{ memattr_survey_baseline.verdict | default('?') | upper }}.
resuming from round {{ memattr_survey_status.completed_rounds }}
of {{ memattr_survey_rounds }}.