ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
checks / unit (pull_request) Successful in 6m57s

quiet when a flag was forgotten

Both blocking findings were reproduced before anything was changed, and both are
the class this card exists to guard against.

B1. THE PIN WAS A COUNT, AND A COUNT CANNOT SEPARATE TWO ROUTES. The rehearsal
reaches the disarmed latch by two independent paths and prints every finding
TWICE per red leg - once in the leg's block, once in the closing summary - so
ONE red leg satisfied `count(...) >= 2`. Measured: shipped code 4; with
`migrate_unit_keys()` handed the roster's own name (SAME-STEP fixed) 2, PASSES;
with a guard-side fix (both fixed) 0. The check named "NOT THE SAME-STEP PATH
ALONE" reported ok on the build whose only remaining failure IS the staged path.
The tool now prints `LEG <id>` / `END LEG <id>` and the pin requires the finding
in EACH leg by name.

⚠️ AND THE FIRST SPLITTER CARRIED THE SAME DEFECT ONE LEVEL DOWN - it opened on
the marker and never closed, so the last leg absorbed the closing summary and
read red whenever ANY leg was red. Caught by building the one fixture that
separates them. Part 5b is now that fixture, run every time: a tree in which
SAME-STEP is genuinely clean and STAGED is not. A per-thing check that has never
seen the things disagree is a total with extra steps.

⚠️ AND IT CORRECTS A CLAIM THIS CARD MADE. `main.py:250` is NOT the whole fix.
Measured on that exact change: LEG SAME-STEP clean, LEG STAGED still DISARMED -
the staged path stamps `schema_version` on the flat start and the roster start's
migration returns early before the name it is handed matters. DOCS.md now says
so, with the measurement.

B2. A FORGOTTEN FLAG WAS A CLEAN BILL OF HEALTH. Omitting `--live-entities`
printed "nothing orphaned" and exited 0, with `grep -ci` for the words returning
0 - while an UNREADABLE file exited 2. The asymmetry was backwards. Omitting it
now refuses (exit 2); `--no-live-entities` is the deliberate opt-out and carries
a standing banner in the header AND on the exit line, because a report is
skimmed from the bottom.

N1. THE REFERENCE DID NOT CARRY WHAT HA KEYS ON. It held entity_ids only, so
renaming `mqtt.BASE` (every unique_id moves) and changing `DEVICE["identifiers"]`
(HA sees a new device) both came back exit 0 - real orphanings with every
composed id byte-identical. The reference is now three columns
(`entity_id unique_id device`), is loaded on EVERY run including a degraded one,
and M7/M8 pin both. `object_id` is deliberately not a column and its rename is
required to SURVIVE, which is what bounds the four mqtt.py mutations.

Also found while fixing the above, and fixed with its own paired positive: the
VANISHED check did not carry the default/non-default rule the STRANDED check
does, so a pair of migrated DEFAULTS moving namespace read as two findings and
CONTROL-C went red on a CORRECT build. M9 drops non-default keys and must still
be a finding.

N2/N3. DOCS.md withdraws the unmeasured "not recoverable by advancing again" -
its own reasoning cut the other way and nothing here tested a re-advance; the
measured part (eight rows stop updating, history gaps, automations stop firing)
stands. And the byte-identical-input property is `test_rehearse.py`'s assertion,
not the tool's.

FRAMING. SAFETY-44 is ALREADY RECORDED on the merge base - `test_maintenance.py`
section 13, with the "guard is defeated by the very key the defect wrote"
sentence, in a file this card does not touch. DOCS.md now says this is
independent re-confirmation through a second instrument, reachable from an
owner-supplied state file rather than only from a fixture, and not a discovery.

app/ is still untouched. 59 checks in test_rehearse.py, 9 mutations red, 2
required survivors, 3 clean controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-08 14:21:34 +02:00
parent 1841416aa4
commit 638c213944
6 changed files with 831 additions and 190 deletions
+304 -87
View File
@@ -267,8 +267,33 @@ def compose(device_name, entity_name) -> str:
ANCHOR_ID = "sensor.goodwe_p1_sample_age"
def entity_ids(opts: dict) -> "dict[str, str]":
"""`{entity_id: unique_id}` for every row this add-on announces on `opts`.
class Row:
"""One announced entity, in the three terms Home Assistant keys on.
⚠️ THE UNIQUE_ID AND THE DEVICE IDENTIFIER ARE NOT DECORATION, AND LEAVING
THEM OUT OF THE COMPARISON WAS THIS TOOL'S SECOND HABIT-4. HA's MQTT
integration keys its ENTITY registry on `unique_id` and its DEVICE registry
on the device identifier. Change either one and HA creates a NEW entity
beside the old - while the COMPOSED entity_id is byte-identical, so a
comparison over composed ids alone reports a clean transition about a change
that orphans the whole install. Measured: renaming `mqtt.BASE` and changing
`DEVICE["identifiers"]` both came back exit 0 before this class existed.
"""
__slots__ = ("entity_id", "unique_id", "device_id")
def __init__(self, entity_id, unique_id, device_id):
self.entity_id = entity_id
self.unique_id = unique_id
self.device_id = device_id
def __repr__(self):
return (f"{self.entity_id} (unique_id {self.unique_id}, device "
f"{self.device_id})")
def announced_rows(opts: dict) -> "dict[str, Row]":
"""`{entity_id: Row}` for every row this add-on announces on `opts`.
⚠️ `discovery_payloads()` IS THE SHIPPED ANNOUNCER and `roster_names()` is
the shipped naming rule, so the roster shape decision is not restated here.
@@ -278,7 +303,9 @@ def entity_ids(opts: dict) -> "dict[str, str]":
omit=() if is_enabled(opts) else ("p1_age",))
out = {}
for _topic, cfg in payloads:
out[compose(cfg["device"]["name"], cfg["name"])] = cfg["unique_id"]
eid = compose(cfg["device"]["name"], cfg["name"])
out[eid] = Row(eid, cfg["unique_id"],
(cfg["device"].get("identifiers") or [""])[0])
return out
@@ -301,20 +328,56 @@ def _check_anchor() -> None:
"The model is wrong; no orphan claim from it can be believed.")
def load_entity_list(path: str) -> set:
"""The entity ids that exist on the install, one per line, `#` comments.
# Shipped beside this file, and LOADED ON EVERY RUN. See `load_reference()`.
DEFAULT_REFERENCE = (Path(__file__).resolve().parent / "rehearsal"
/ "announced-reference.txt")
⚠️ EXTERNAL DATA, AND THE TOOL IS BLIND WITHOUT IT. The before/after diff is
composed twice by the SAME build, so a change to the id composition itself -
a renamed MQTT device, a renamed sensor - moves both sides equally and the
diff reports nothing. `tools/rehearsal/live-entities-recorded.txt` carries
the recorded list and DOCS.md's owner step replaces it with a real export.
def load_reference(path) -> "dict[str, Row]":
"""What this build is SUPPOSED to announce: `<entity_id> <unique_id> <dev>`.
⚠️ EXTERNAL DATA, AND THE TOOL IS BLIND WITHOUT IT - which is why it is
loaded unconditionally and a missing one is EXIT 2 rather than a quieter
run. The before/after diff is composed twice by the SAME build, so a change
to the composition itself moves both sides equally and the diff reports
nothing. Only a reference that does not move with the code can see it.
⚠️ THREE COLUMNS ARE REQUIRED AND A SHORT LINE IS REFUSED. A two-column
reference would silently stop grading device identifiers, which is exactly
the blind spot this file exists to close - and it would do it without
saying so, which is the failure mode the whole card is about.
"""
rows = {}
for lineno, line in enumerate(
Path(path).read_text(encoding="utf-8").splitlines(), 1):
line = line.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
if len(parts) != 3:
raise Refused(
f"{path} line {lineno}: expected `<entity_id> <unique_id> "
f"<device identifier>`, got {len(parts)} field(s). A short "
f"line would silently stop grading what HA keys on.")
rows[parts[0]] = Row(*parts)
if not rows:
raise Refused(f"{path}: no rows in it")
return rows
def load_entity_list(path: str) -> set:
"""The entity ids that EXIST ON THE INSTALL, one per line, `#` comments.
This is the owner's export from Home Assistant, and it answers a different
question from `load_reference()`: that one says what the BUILD announces,
this one says what the INSTALL has. An id the install has and the new shape
does not announce is an orphan whatever the build's own history says.
"""
out = set()
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.add(line)
out.add(line.split()[0])
if not out:
raise Refused(f"{path}: no entity ids in it")
return out
@@ -359,7 +422,7 @@ class Snapshot:
self.units = [inv.name for inv in controller.inverters]
self.data = dict(store.data)
self.read = set(store.data.reads)
self.entities = entity_ids(opts)
self.entities = announced_rows(opts)
self.latched = {inv.name: bool(inv.sign_wd.tripped)
for inv in controller.inverters}
self.latch_reason = {inv.name: inv.sign_wd.reason
@@ -371,9 +434,11 @@ class Snapshot:
self.store_migration = controller.store_migration
self.maint_migration = controller.maint_migration
self.machine_read = machine_reads(controller, store)
# Keys no machine reads whose value is the default `Store.load()` would
# have supplied. Reported, never a finding: see `diff()`.
# Keys no machine reads, and keys that disappeared, whose value is the
# default `Store.load()` would have supplied. Reported, never a
# finding: see `diff()`.
self.stranded_at_default = []
self.vanished_at_default = []
@property
def unit_keys(self):
@@ -400,8 +465,15 @@ def machine_reads(controller, store) -> set:
Inverter(inv.name, store=store)
for m in controller.maints:
for key in MAINT_UNIT_KEYS:
# ⚠️ `get()` AND NOT ALSO `get_time()`. Both route through
# `store.data.get(self.key(name))`, so either one records the read
# this probe exists to record - but `get_time()` on `phase`, which
# holds "idle" and not a timestamp, makes `Store.get_time()` log
# "unparseable timestamp" at WARNING. A probe that manufactures
# alarming log lines about a healthy store teaches an operator to
# ignore this tool's stderr, which is where the controller's own
# real warnings appear.
m.store.get(key)
m.store.get_time(key)
seen = set(store.data.reads)
store.data.reads.clear()
return seen
@@ -442,11 +514,24 @@ def diff(before: Snapshot, after: Snapshot) -> list:
findings = []
# -- store keys ---------------------------------------------------------
gone = sorted(set(before.data) - set(after.data))
for key in gone:
# ⚠️ THE SAME DEFAULT/NON-DEFAULT RULE THE STRANDED CHECK USES, AND FOR THE
# SAME REASON - `MigrationReport.lost`'s, in store.py: a dropped
# `sign_tripped = False` has lost the DEFAULT, which is the statement
# "absent" already makes, so nothing is gone; a dropped `True` has lost a
# LATCH. Applying it to STRANDED and not here was an inconsistency with a
# cost: a store whose flat start wrote the two SAFETY-02 defaults under one
# name and whose roster start writes them under another reported two
# VANISHED findings about a pair of defaults, and CONTROL-C went red on a
# build that was CORRECT.
for key in sorted(set(before.data) - set(after.data)):
value = before.data[key]
bare = key.rsplit("/", 1)[-1]
if value in (None, DEFAULTS.get(bare)):
after.vanished_at_default.append(f"{key} = {value!r}")
continue
findings.append(Finding(
"VANISHED STORE KEY",
f"{key} = {before.data[key]!r} was in the store before the "
f"{key} = {value!r} was in the store before the "
f"transition and is not there afterwards"))
# ⭐ THE ONE SAFETY-44 IS ABOUT. A key that is present, correct, verified by
@@ -536,38 +621,91 @@ def _latch_finding(key, before, after):
return out
def entity_list_findings(recorded: set, before: Snapshot,
after: Snapshot) -> list:
"""Grade both snapshots against a list that does NOT move with the code."""
# ⚠️ THE REFERENCE IS GRADED AT A FIXED, CANONICAL SHAPE AND NOT AT THE
# OPERATOR'S CONFIG. The reference records what `release/1.0` announces at
# `meter_source: firmware`, roster of one. Grading it against the BEFORE config
# instead asks two questions at once and gets a wrong answer to both: a site
# with `meter_source: off` legitimately omits `p1_age` (see `discovery_payloads`
# and `is_enabled`), so the tool reported "the reference records
# sensor.goodwe_p1_sample_age and this build does not announce it" about a
# perfectly correct build. The question here is ONLY "has this build's
# composition drifted", which is a property of the CODE, so it is asked of the
# code at the shape the reference was taken at.
REFERENCE_PROBE_OPTS = {"meter_source": "firmware"}
def reference_findings(reference: "dict[str, Row]") -> list:
"""Does this BUILD still announce what the reference records?
⚠️ RUN UNCONDITIONALLY, AND OVER ALL THREE TERMS HA KEYS ON. The
before/after diff cannot see a change to the composition, because both of
its sides are composed by the same build. This is the only check in the
tool whose reference does not move with the code, so it is the only one
that can - and it is graded on the entity_id, the `unique_id` AND the
device identifier, because a change to either of the latter two orphans an
install while leaving every composed entity_id byte-identical.
"""
out = []
announced_before = set(before.entities)
announced_after = set(after.entities)
for eid in sorted(announced_before - recorded):
built = announced_rows(REFERENCE_PROBE_OPTS)
for eid in sorted(set(built) - set(reference)):
out.append(Finding(
"COMPOSITION DRIFT",
f"this build announces {eid} in the BEFORE shape, and that id is "
"not on the recorded list of ids the install has. The id "
"composition has changed under the install: Home Assistant keeps "
"the id it composed at first discovery, so what this actually "
"creates is a NEW entity beside a dead one."))
owned = recorded & announced_before
for eid in sorted(owned - announced_after):
f"this build announces {eid} and the reference does not record "
f"it. The composed entity_id has changed: Home Assistant keeps the "
f"id it composed at FIRST discovery, so what this creates is a NEW "
f"entity beside one that stops updating."))
for eid in sorted(set(reference) - set(built)):
out.append(Finding(
"ORPHANED ENTITY (against the recorded list)",
"COMPOSITION DRIFT",
f"the reference records {eid} and this build does NOT announce it "
f"at the shape the reference was taken at "
f"({REFERENCE_PROBE_OPTS})."))
for eid in sorted(set(reference) & set(built)):
want, got = reference[eid], built[eid]
if want.unique_id != got.unique_id:
out.append(Finding(
"UNIQUE_ID DRIFT",
f"{eid} is announced with unique_id {got.unique_id!r} where "
f"the reference records {want.unique_id!r}. ⚠️ THE COMPOSED "
f"ENTITY_ID IS UNCHANGED, WHICH IS WHY THIS NEEDS ITS OWN "
f"CHECK: HA's MQTT entity registry keys on `unique_id`, so a "
f"changed one is a NEW entity - and discovery is retained with "
f"no removal payload, so the old config stays on the broker "
f"and the old entity stays in the registry, named by every "
f"automation that referenced it and never updating again."))
if want.device_id != got.device_id:
out.append(Finding(
"DEVICE IDENTIFIER DRIFT",
f"{eid} is announced on device {got.device_id!r} where the "
f"reference records {want.device_id!r}. HA's device registry "
f"keys on the identifier, so this is a NEW device carrying new "
f"entities, beside the old device and its dead ones."))
return out
def install_findings(installed: set, before: Snapshot,
after: Snapshot) -> list:
"""Grade the transition against the ids the INSTALL actually has."""
out = []
owned = installed & set(before.entities)
for eid in sorted(owned - set(after.entities)):
out.append(Finding(
"ORPHANED ENTITY (against the install's own entity list)",
f"{eid} exists on the install and is announced in the BEFORE "
"shape, and the AFTER shape does not announce it."))
return out
def report_entity_list(recorded: set, before: Snapshot, after: Snapshot) -> None:
def report_entity_list(installed: set, before: Snapshot,
after: Snapshot) -> None:
announced_before = set(before.entities)
foreign = sorted(recorded - announced_before)
foreign = sorted(installed - announced_before)
print()
print(" -- AGAINST THE RECORDED ENTITY LIST ------------------------------")
print(f" {len(recorded)} ids on the list, "
f"{len(recorded & announced_before)} of them announced by this "
print(" -- AGAINST THE INSTALL'S OWN ENTITY LIST -------------------------")
print(f" {len(installed)} ids on the install, "
f"{len(installed & announced_before)} of them announced by this "
f"add-on in the BEFORE shape")
print(f" {len(foreign)} on the list that this add-on announces in "
print(f" {len(foreign)} on the install that this add-on announces in "
"NEITHER shape - the roster switch cannot orphan these, they belong "
"to the")
print(" ESPHome firmware, to template sensors or to helpers:")
@@ -638,9 +776,19 @@ def _round_trip_findings(completed: str, readvanced: Snapshot) -> list:
return out
def report_transition(name, before, after, findings) -> None:
def report_transition(leg_id, name, before, after, findings) -> None:
"""One leg of the rehearsal.
⚠️ `LEG <id>` IS A MACHINE-PARSEABLE MARKER AND IT IS LOAD-BEARING. Every
finding is printed twice - once here and once in the closing summary - so
COUNTING a finding's text across the whole report cannot tell ONE red leg
from TWO. `test_rehearse.py` part 5 was written that way and reported `ok`
on a build whose failure was a single leg. The pin now splits the report on
these markers and asks each leg separately.
"""
print()
print("=" * 78)
print(f" LEG {leg_id}")
print(f" {name}")
print("=" * 78)
print(f" roster before : {before.units}")
@@ -677,14 +825,16 @@ def report_transition(name, before, after, findings) -> None:
mark = " " if key in after.data else " (absent, defaulted) "
print(f" {key}{mark}")
if after.stranded_at_default:
if after.stranded_at_default or after.vanished_at_default:
print()
print(" keys no machine reads whose value IS the default, so "
"nothing is lost by")
print(" stranding them - reported rather than hidden, not counted "
"as findings:")
print(" keys whose value IS the default `Store.load()` supplies, so "
"nothing is lost")
print(" by stranding or dropping them - reported rather than "
"hidden, not findings:")
for key in after.stranded_at_default:
print(f" {key} = {after.data[key]!r}")
print(f" stranded {key} = {after.data[key]!r}")
for text in after.vanished_at_default:
print(f" vanished {text}")
print()
print(" -- MAINTENANCE HISTORY (MAINT-05: a second move must not drop it) -")
@@ -710,20 +860,31 @@ def report_transition(name, before, after, findings) -> None:
print()
print(" -- ENTITY IDS ----------------------------------------------------")
allids = sorted(set(before.entities) | set(after.entities))
print(f" {'entity_id':<44} {'unique_id':<30} {'device':<26} state")
for eid in allids:
row = after.entities.get(eid) or before.entities[eid]
inb, ina = eid in before.entities, eid in after.entities
mark = "both" if (inb and ina) else ("ORPHANED" if inb else "new")
print(f" {eid:<48} {mark}")
print(f" {eid:<44} {row.unique_id:<30} {row.device_id:<26} {mark}")
print(f" {len(before.entities)} announced before, "
f"{len(after.entities)} after")
print()
print(" -- FINDINGS ------------------------------------------------------")
print(f" -- FINDINGS in LEG {leg_id} "
+ "-" * max(0, 50 - len(str(leg_id))))
if not findings:
print(" none: nothing vanished, nothing stranded, nothing orphaned, "
"no history lost")
print(f" none in LEG {leg_id}: nothing vanished, nothing stranded, "
"nothing orphaned, no history lost")
for f in findings:
print(f" ⚠️ {f}")
# ⚠️ AN EXPLICIT CLOSE, AND IT IS NOT TIDINESS. Without it a reader that
# splits on the opening markers alone gives every line after the LAST one
# to that leg - including the closing summary, which repeats every finding
# from EVERY leg. The last leg then reads red whenever any leg is red, and
# a per-leg check built on that is the same defect it was written to fix,
# one level down. Measured: with only the opening marker, a build whose
# SAME-STEP leg was clean still reported SAME-STEP disarmed.
print(f" END LEG {leg_id}")
def main(argv=None) -> int:
@@ -736,20 +897,29 @@ def main(argv=None) -> int:
help="the options being switched to (declared roster)")
ap.add_argument("--reverse", action="store_true",
help="also rehearse the RETREAT: roster -> flat")
ap.add_argument("--live-entities", default=None,
help="a file of entity ids the install actually has, one "
"per line. ⚠️ WITHOUT IT THE TOOL CANNOT SEE A CHANGE "
"TO THE ID COMPOSITION, because both sides of its own "
"diff are composed by the same build. "
"tools/rehearsal/live-entities-recorded.txt is the "
"recorded default; DOCS.md's owner step replaces it "
"with a real export.")
ap.add_argument("--round-trip", action="store_true",
help="also rehearse RETREAT then RE-ADVANCE, with a "
"maintenance cycle completing while retreated. ⚠️ A "
"retreat is only a retreat if you can come back from "
"it, so the round trip is the leg that decides "
"whether the reverse direction is supported.")
ap.add_argument("--reference", default=str(DEFAULT_REFERENCE),
help="what this build is SUPPOSED to announce: "
"`<entity_id> <unique_id> <device identifier>` per "
"line. Loaded on EVERY run; a missing or malformed "
"one is exit 2, because it is the only reference in "
"this tool that does not move with the code.")
ap.add_argument("--live-entities", default=None,
help="a file of the entity ids the INSTALL actually has, "
"one per line - the owner's export from Home "
"Assistant. Orphans are graded against it.")
ap.add_argument("--no-live-entities", action="store_true",
help="⚠️ RUN WITHOUT THE INSTALL'S ENTITY LIST, ACCEPTING "
"THAT THE ORPHAN CHECK IS NOT GRADED AGAINST "
"REALITY. Required to omit --live-entities; the "
"report then carries a standing banner and the exit "
"line says so, because a forgotten flag must never "
"look like a clean bill of health.")
ap.add_argument("--paths", default="both", choices=("staged", "same-step",
"both"),
help="staged: the box runs the BEFORE config once, then "
@@ -759,45 +929,81 @@ def main(argv=None) -> int:
"Default: both.")
args = ap.parse_args(argv)
# ⚠️ THE ASYMMETRY THAT USED TO BE HERE WAS BACKWARDS AND IT IS THE WHOLE
# REASON THIS BLOCK EXISTS. A `--live-entities` file that could not be READ
# refused with exit 2; the flag simply not being PASSED produced "NO
# FINDINGS" and exit 0, with nothing in the report to say half the orphan
# check had not run - measured, `grep -ci` for the words returned 0. The
# owner runs this ONCE, at commissioning, under time pressure. A forgotten
# flag must not hand back a clean bill of health for the half of the
# transition that orphans entities. So omitting it is now a REFUSAL, and
# accepting the weaker run has to be said out loud.
try:
if not args.live_entities and not args.no_live_entities:
raise Refused(
"no --live-entities. The orphan check is graded against the "
"ids the INSTALL has, and without that file it is not graded "
"against reality at all. Export it (DOCS.md, 'Before you "
"commission inverter B'), or pass --no-live-entities to "
"accept the weaker run - which is then stated in the report "
"and on the exit line rather than left to be assumed.")
if args.live_entities and args.no_live_entities:
raise Refused("--live-entities and --no-live-entities are "
"contradictory; pass one.")
_check_anchor()
reference = load_reference(args.reference)
before_opts = load_options(args.before)
after_opts = load_options(args.after)
installed = (load_entity_list(args.live_entities)
if args.live_entities else None)
if not Path(args.state).is_file():
raise Refused(f"{args.state}: not a file")
except Refused as err:
except (OSError, Refused) as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
degraded = installed is None
print(__doc__.split("EXIT CODES")[0].rstrip())
print()
print(f" state file (copied, never written): {args.state}")
print(f" before config: {args.before}")
print(f" after config: {args.after}")
print(f" reference : {args.reference} ({len(reference)} rows, graded "
f"on entity_id + unique_id + device identifier)")
if degraded:
print()
print(" " + "!" * 74)
print(" !! DEGRADED RUN: --no-live-entities. The ORPHAN CHECK IS NOT "
"GRADED AGAINST")
print(" !! THE INSTALL. This run can still see the composition "
"drifting away from")
print(" !! the reference, and it can see the store migration - but it "
"cannot tell")
print(" !! you that an entity the install HAS stops being announced, "
"because it")
print(" !! has not been told what the install has. ⚠️ A CLEAN RESULT "
"BELOW IS NOT A")
print(" !! CLEAN BILL OF HEALTH FOR THE ENTITIES.")
print(" " + "!" * 74)
else:
print(f" install list : {args.live_entities} ({len(installed)} ids)")
findings = []
recorded = None
try:
if args.live_entities:
recorded = load_entity_list(args.live_entities)
except (OSError, Refused) as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
tmpdir = tempfile.mkdtemp(prefix="rehearse-roster-")
try:
if args.paths in ("staged", "both"):
b = snapshot("staged-before", before_opts, args.state, tmpdir)
a = snapshot_over("staged-after", after_opts, b)
f = diff(b, a)
if recorded:
f += entity_list_findings(recorded, b, a)
f = diff(b, a) + reference_findings(reference)
if installed is not None:
f += install_findings(installed, b, a)
report_transition(
"STAGED: the box runs the flat config, then restarts on the "
"roster", b, a, f)
if recorded:
report_entity_list(recorded, b, a)
print(" (findings from the recorded list are in the list "
"above)")
"STAGED",
"the box runs the flat config, then restarts on the roster",
b, a, f)
if installed is not None:
report_entity_list(installed, b, a)
findings += f
if args.paths in ("same-step", "both"):
@@ -807,12 +1013,13 @@ def main(argv=None) -> int:
# version that knew about namespaces.
b2 = snapshot("samestep-before", before_opts, args.state, tmpdir)
a2 = snapshot("samestep-after", after_opts, args.state, tmpdir)
f2 = diff(b2, a2)
if recorded:
f2 += entity_list_findings(recorded, b2, a2)
f2 = diff(b2, a2) + reference_findings(reference)
if installed is not None:
f2 += install_findings(installed, b2, a2)
report_transition(
"SAME-STEP (SAFETY-44): a pre-DEV-02 flat store meets a "
"declared roster on its FIRST start under this version",
"SAME-STEP",
"SAFETY-44's path: a pre-DEV-02 flat store meets a declared "
"roster on its FIRST start under this version",
b2, a2, f2)
findings += f2
@@ -820,9 +1027,12 @@ def main(argv=None) -> int:
r1 = snapshot("retreat-before", after_opts, args.state, tmpdir)
r2 = snapshot_over("retreat-after", before_opts, r1)
fr = diff(r1, r2)
if installed is not None:
fr += install_findings(installed, r1, r2)
report_transition(
"THE RETREAT: roster -> flat, which is the back-out if "
"commissioning B goes wrong", r1, r2, fr)
"RETREAT",
"roster -> flat, which is the back-out if commissioning B "
"goes wrong", r1, r2, fr)
findings += fr
if args.round_trip:
@@ -839,8 +1049,9 @@ def main(argv=None) -> int:
q3 = snapshot_over("roundtrip-readvanced", after_opts, q2)
fq = diff(q2, q3) + _round_trip_findings(worked, q3)
report_transition(
"THE ROUND TRIP: roster -> flat -> roster, with a maintenance "
f"cycle completing at {worked!r} while retreated",
"ROUND-TRIP",
f"roster -> flat -> roster, with a maintenance cycle "
f"completing at {worked!r} while retreated",
q2, q3, fq)
findings += fq
except Refused as err:
@@ -855,11 +1066,17 @@ def main(argv=None) -> int:
print(f" {len(findings)} FINDING(S) - this transition is not clean")
for f in findings:
print(f" ⚠️ {f}")
print("=" * 78)
return EXIT_FINDING
print(" NO FINDINGS in the rehearsed transitions")
else:
print(" NO FINDINGS in the rehearsed transitions")
# ⚠️ ON THE EXIT LINE, NOT ONLY IN THE HEADER. A report is skimmed from the
# bottom.
if degraded:
print(" ⚠️ DEGRADED RUN (--no-live-entities): the orphan check was "
"NOT graded against")
print(" the install's own entity list. This result does not cover "
"orphaned entities.")
print("=" * 78)
return EXIT_OK
return EXIT_FINDING if findings else EXIT_OK
if __name__ == "__main__":