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]>
99 lines
5.2 KiB
YAML
99 lines
5.2 KiB
YAML
---
|
|
# ONE round on ONE node: reboot, wait for it back, measure the new boot, record it twice.
|
|
# Included in a loop from tasks/main.yml with loop_var `memattr_survey_round`.
|
|
#
|
|
# FAIL-SAFE, PER NODE. The block/rescue below is what keeps one dead node from ending the
|
|
# survey: on any failure this host sets memattr_survey_aborted and every later round is
|
|
# skipped FOR THIS HOST ONLY, while the other nodes keep going (Ansible runs the loop for
|
|
# all hosts in lockstep, so the reboots are genuinely concurrent). The abort is reported
|
|
# loudly here AND again in the final analysis, which counts rounds from the artifacts
|
|
# rather than from Ansible's host state — so a node that vanishes so hard that it is
|
|
# marked unreachable (which rescue cannot catch) still shows up as a short arm.
|
|
|
|
- name: "Round {{ memattr_survey_round }} — survey step"
|
|
block:
|
|
- name: "Round {{ memattr_survey_round }} — reboot and wait for the node to return"
|
|
ansible.builtin.reboot:
|
|
reboot_timeout: "{{ memattr_survey_reboot_timeout | int }}"
|
|
connect_timeout: "{{ memattr_survey_connect_timeout | int }}"
|
|
post_reboot_delay: "{{ memattr_survey_post_reboot_delay | int }}"
|
|
msg: "memattr survey round {{ memattr_survey_round }} of {{ memattr_survey_rounds }}"
|
|
|
|
- name: "Round {{ memattr_survey_round }} — measure the new boot and append to the ledger"
|
|
ansible.builtin.script:
|
|
cmd: >-
|
|
memattr_probe.py --round {{ memattr_survey_round }}
|
|
--append {{ memattr_survey_state_file | quote }}
|
|
executable: /usr/bin/python3
|
|
register: memattr_survey_round_raw
|
|
changed_when: false # see the note in preflight.yml — the ledger is not the node
|
|
|
|
- name: "Round {{ memattr_survey_round }} — parse the record"
|
|
ansible.builtin.set_fact:
|
|
memattr_survey_record: "{{ memattr_survey_round_raw.stdout | from_json }}"
|
|
|
|
- name: "Round {{ memattr_survey_round }} — result"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
[{{ inventory_hostname }} r{{ memattr_survey_round }}/{{ memattr_survey_rounds }}]
|
|
MEMATTR={{ memattr_survey_record.memattr_address | default('none') }}
|
|
read={{ memattr_survey_record.kernel_read }}
|
|
{%- if memattr_survey_record.memattr_table_version is not none %}
|
|
(version {{ memattr_survey_record.memattr_table_version }})
|
|
{%- endif %}
|
|
MemTotal={{ (memattr_survey_record.mem_total_kb | default(0) | int / 1048576) | round(2) }}G
|
|
of {{ (memattr_survey_record.smbios_installed_kb | default(0) | int / 1048576) | round(0) | int }}G
|
|
=> {{ memattr_survey_record.verdict | upper }}
|
|
boot={{ memattr_survey_record.boot_id | default('?') | truncate(9, true, '') }}
|
|
when: memattr_survey_show_progress | bool
|
|
|
|
# BRACES. The on-node ledger already survives a reboot; re-fetching it every round
|
|
# means the controller also holds every completed round if the DRIVER dies, without
|
|
# waiting for the end-of-run harvest. One tiny file per host, per-host destination
|
|
# paths, so three concurrent fetches cannot collide.
|
|
- name: "Round {{ memattr_survey_round }} — mirror the ledger to the controller"
|
|
ansible.builtin.fetch:
|
|
src: "{{ memattr_survey_state_file }}"
|
|
dest: "{{ memattr_survey_artifact_dir }}/{{ inventory_hostname }}.rounds.jsonl"
|
|
flat: true
|
|
changed_when: false
|
|
when: memattr_survey_write_artifacts | bool
|
|
|
|
rescue:
|
|
- name: "Round {{ memattr_survey_round }} — ABORT this node, continue the others"
|
|
ansible.builtin.set_fact:
|
|
memattr_survey_aborted: true
|
|
memattr_survey_abort_reason: >-
|
|
round {{ memattr_survey_round }} failed:
|
|
{{ ansible_failed_result.msg | default('unknown error') | trim }}
|
|
|
|
# The abort must survive the run, not live only in terminal scrollback. Without
|
|
# this the report can see that an arm is SHORT but not WHY, and "0 rounds" reads
|
|
# like a targeting mistake instead of a node that failed to come back.
|
|
- name: "Round {{ memattr_survey_round }} — record the abort as an artifact"
|
|
ansible.builtin.copy:
|
|
dest: "{{ memattr_survey_artifact_dir }}/{{ inventory_hostname }}.aborted.json"
|
|
mode: "0644"
|
|
content: "{{ memattr_survey_abort_record | to_nice_json }}"
|
|
vars:
|
|
memattr_survey_abort_record:
|
|
schema: "memattr-survey/1"
|
|
host: "{{ inventory_hostname }}"
|
|
aborted_at_round: "{{ memattr_survey_round }}"
|
|
target_rounds: "{{ memattr_survey_rounds }}"
|
|
reason: "{{ memattr_survey_abort_reason | trim }}"
|
|
failed_task: "{{ ansible_failed_task.name | default('unknown') }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
changed_when: false
|
|
when: memattr_survey_write_artifacts | bool
|
|
|
|
- name: "Round {{ memattr_survey_round }} — announce the abort"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
*** SURVEY ABORTED ON {{ inventory_hostname }} at round
|
|
{{ memattr_survey_round }}/{{ memattr_survey_rounds }}:
|
|
{{ memattr_survey_abort_reason }}
|
|
*** The other nodes continue. This node's arm is SHORT — the analysis will
|
|
say so, and its p(bad) interval will be correspondingly wider.
|