Files
goodwe-addon/goodwe_controller/tools/rehearse_roster.py
T
Claude Opus 5 cb8f800180 ARCH-02: the harness was red on every fresh install, and a third control says so
A store the add-on has never written has no latch and no history. The DEV-02
migration still runs on it - `schema_version` is absent, so `from_version` is 0 -
and it writes `sign_tripped = False` and `sign_reason = ""` under `inverter-a`
before `build_roster()` exists. On a declared roster nothing reads either of
them, so the first draft called both STRANDED and exited 1. Every fresh install
that grows a second inverter would have been reported as a failed transition.

⚠️ THAT IS A DEFECT IN THE ALARM, NOT A CASE TO SUPPRESS, and store.py already
carries the distinction it needed: `MigrationReport.lost` reports a dropped
`sign_tripped=True` and NOT a dropped `sign_tripped=False`, because "absent" and
"never tripped" are the same statement - and because an alarm that fires on the
harmless case is one an operator learns to dismiss before the real one arrives.
The stranded check now makes the same split, on the VALUE and not on the key
name: a key stranded at its default is printed under its own heading, visibly,
and is not a finding; the same key stranded at a non-default still is. SAFETY-44
turns on `sign_tripped = True`, so it is untouched, and a check pins both
halves against each other so the softening cannot widen into a hole.

CONTROL-C is the fresh install, and it is a third clean control rather than a
line in a comment: the shipped code is right about it and the harness must say
nothing. It is the control that found this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:20:49 +02:00

867 lines
37 KiB
Python

"""ARCH-02: rehearse the flat -> roster switch against a COPY of real state.
python3 tools/rehearse_roster.py --state <path> \
--before <flat.yaml> --after <roster.yaml>
WHAT THIS IS FOR, in one sentence: on the day inverter B is commissioned the
add-on stops reading the flat single-inverter options and starts reading a
declared `inverters:` roster, and two things happen at that restart that are
ONE-WAY and INVISIBLE UNTIL AFTERWARDS - the store migration renames the keys
that hold a safety latch and a month of maintenance history, and the entity ids
Home Assistant composes change shape.
THE STORE. /data/state.json on the live box was written by a flat-config
controller. A latch that migrates to a name nothing reads is a
SAFETY GUARD THAT COMES UP DISARMED on an install whose state
file says it is protected (store.py's own header, and SAFETY-02:
"a latch that a restart clears is not a latch"). MAINT-05 had to
fix exactly this shape once already.
THE ENTITIES. Home Assistant keeps the entity_id it composed at FIRST
discovery. An id that stops being announced is not deleted - it
is ORPHANED: history stays behind on the dead id, and every
automation whose trigger names it SILENTLY STOPS FIRING. An
automation with a vanished trigger entity does not error. It
never runs again.
⚠️ THIS TOOL RUNS THE REAL `Controller`, NOT A MODEL OF IT. `snapshot()` builds
`app.main.Controller` over the real `app.store.Store`, so the migration under
test is the shipped one and the readers that are asked "can you still find your
latch" are the shipped readers. A rehearsal that re-implemented the migration
would be comparing a structure to itself, which is `MUTATION.md` habit 4 and
this repository's most-repeated defect.
⚠️ IT NEVER WRITES TO THE FILE IT IS GIVEN. `--state` is opened once, copied to
a scratch file made with `mkstemp()`, and every `Store.save()` in the rehearsal
lands on the copy. The scratch file is unique per invocation, which is habit 18.
⚠️ IT DOES NOT TOUCH THE LIVE BOX AND CANNOT. There is no SSH, no Supervisor
call and no HTTP in this file. Fetching the state file is an OWNER STEP and it
is written down in DOCS.md under "Rehearsing the roster switch (ARCH-02)".
EXIT CODES
0 the transition is clean: nothing vanished, nothing was stranded under a
name no machine reads, no entity was orphaned, no history was lost.
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
reachable: `test_rehearse.py` mutates the shipped migration five
different ways and requires this exit code for each.
2 the rehearsal could not be performed (bad file, unparseable config, the
entity-id model failed its own anchor). ⚠️ NOT A PASS.
"""
import argparse
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa: E402
from app.main import Controller # noqa: E402
from app import mqtt # noqa: E402
from app.p1 import is_enabled # noqa: E402
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
unit_key)
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
class Refused(Exception):
"""The rehearsal could not be performed. Exit 2, never exit 0."""
# --------------------------------------------------------------------------- #
# 1. THE CONFIGS
# --------------------------------------------------------------------------- #
# The live box's options blob is JSON (`/data/options.json`); the card's command
# line spells the two configs as `.yaml` because that is what an operator edits.
# Both are accepted. ⚠️ THE YAML READER REFUSES WHAT IT DOES NOT UNDERSTAND
# RATHER THAN GUESSING: a config silently mis-parsed here would produce a
# rehearsal of a configuration nobody is going to deploy, which is worse than no
# rehearsal at all. PyYAML is used when it is importable; the add-on's check
# image has no `yaml` module, so the subset below is what actually runs in CI.
def _scalar(text: str):
raw = text.strip()
if raw.startswith("#"):
raise Refused("comment where a value was expected")
if (raw.startswith('"') and raw.endswith('"') and len(raw) >= 2) or \
(raw.startswith("'") and raw.endswith("'") and len(raw) >= 2):
return raw[1:-1]
low = raw.lower()
if low in ("true", "yes", "on"):
return True
if low in ("false", "no", "off"):
return False
if low in ("null", "~", ""):
return None
if raw == "[]":
return []
if raw == "{}":
return {}
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
if raw.startswith("[") or raw.startswith("{"):
raise Refused(f"inline collection {raw!r} is not supported by the "
"built-in reader - install PyYAML or use JSON")
return raw
def _mini_yaml(text: str) -> dict:
"""The subset an add-on options file is written in, and nothing else.
Block mappings, and lists of block mappings (`inverters:`). Two indent
levels of nesting, which is all `config.yaml`'s option surface has. Every
other construct raises `Refused`.
"""
root: dict = {}
cur_list = None
cur_entry = None
list_indent = None
for lineno, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
if "\t" in line[:indent]:
raise Refused(f"line {lineno}: tab in indentation")
body = line.strip()
try:
if body.startswith("- "):
if cur_list is None:
raise Refused(f"line {lineno}: list item outside a key")
if list_indent is None:
list_indent = indent
if indent != list_indent:
raise Refused(f"line {lineno}: ragged list indentation")
cur_entry = {}
cur_list.append(cur_entry)
item = body[2:].strip()
if item:
k, _, v = item.partition(":")
if not _:
raise Refused(f"line {lineno}: bare list scalar")
cur_entry[k.strip()] = _scalar(v)
continue
if ":" not in body:
raise Refused(f"line {lineno}: {body!r} is not `key: value`")
key, _, value = body.partition(":")
key, value = key.strip(), value.strip()
if cur_entry is not None and list_indent is not None \
and indent > list_indent:
cur_entry[key] = _scalar(value)
continue
cur_list = cur_entry = None
list_indent = None
if value == "":
cur_list = root[key] = []
continue
root[key] = _scalar(value)
except Refused:
raise
# A key written `foo:` with nothing under it is an EMPTY list here, which is
# exactly what `inverters: []` means to `roster_entries()`.
return root
def load_options(path: str) -> dict:
text = Path(path).read_text(encoding="utf-8")
try:
obj = json.loads(text)
except ValueError:
try:
import yaml # noqa: PLC0415
except ImportError:
obj = _mini_yaml(text)
else:
obj = yaml.safe_load(text)
if not isinstance(obj, dict):
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
return obj
# --------------------------------------------------------------------------- #
# 2. THE STORE, AND THE READ LOG THAT MAKES "NOTHING READS THIS" MEASURABLE
# --------------------------------------------------------------------------- #
class _ReadLog(dict):
"""`Store.data`, with every value-read remembered.
⚠️ THIS IS THE INSTRUMENT THE WHOLE SAFETY-44 QUESTION TURNS ON. "A key
nothing reads" cannot be established by reasoning about which name the
migration was handed; it is established by asking the SHIPPED READERS to
restore themselves and recording which keys they actually touched. A key in
the file that no reader touched is a key whose value is not information.
`__contains__` is deliberately NOT recorded: `_move_flat_keys()` tests
`target in data` to decide whether a namespaced value already exists, and
that is a write-side decision, not a machine reading its latch.
"""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.reads = set()
def get(self, key, default=None):
self.reads.add(key)
return super().get(key, default)
def __getitem__(self, key):
self.reads.add(key)
return super().__getitem__(key)
class RehearsalStore(Store):
"""The SHIPPED `Store`, pointed at a scratch copy of the operator's file.
⚠️ THE REAL CLASS, NOT A STAND-IN. `load()`, `save()`, `get_time()` and
`set_time()` are the ones the add-on runs, so the ISO-8601 round trip that
the module docstring says once took the controller down for hours is the
round trip under rehearsal. A `FakeStore` here would rehearse the fake.
"""
def __init__(self, path: str):
super().__init__(path)
self.data = _ReadLog(self.data)
self.data.reads.clear()
def _scratch_copy(state_path: str, tmpdir: str, tag: str) -> str:
"""A per-invocation copy. ⚠️ The operator's file is never opened for write."""
dest = os.path.join(tmpdir, f"state-{tag}.json")
if state_path is None:
Path(dest).write_text("{}", encoding="utf-8")
else:
shutil.copyfile(state_path, dest)
return dest
# --------------------------------------------------------------------------- #
# 3. THE ENTITY IDS
# --------------------------------------------------------------------------- #
# ⚠️ THE SLUGIFIER IS DELIBERATELY NOT `mqtt._slug`. Importing the module's own
# spelling would make the model and the subject move together, so a mutation in
# the composition would change both sides equally and this tool would report
# "no orphans" about a rename that orphaned everything - habit 4, written in one
# import. `test_mqtt.py` part 6 makes the same move for the same reason.
import re # noqa: E402
def compose(device_name, entity_name) -> str:
"""The entity_id Home Assistant composes, per `app/mqtt.py`'s own note."""
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
return "sensor." + s.strip("_")
# The ONE id verified against the live system (2026-08-26, recorded in
# `app/mqtt.py`) and subscribed to by the ESP32 ingest guard. If the model above
# cannot reproduce it, the model is wrong and every orphan claim below it is
# worthless - so the tool REFUSES rather than reporting.
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`.
⚠️ `discovery_payloads()` IS THE SHIPPED ANNOUNCER and `roster_names()` is
the shipped naming rule, so the roster shape decision is not restated here.
"""
payloads = mqtt.discovery_payloads(
units=roster_names(opts),
omit=() if is_enabled(opts) else ("p1_age",))
out = {}
for _topic, cfg in payloads:
out[compose(cfg["device"]["name"], cfg["name"])] = cfg["unique_id"]
return out
# The device name and entity name that composed it, recorded as LITERALS.
# ⚠️ LITERALS, NOT `mqtt.DEVICE["name"]`. Anchoring on the shipped dict would
# make this check pass for any renaming of the device - the model and the
# subject would move together and the anchor would be decoration. These two
# strings are what `release/1.0` had on the day the id was verified.
ANCHOR_DEVICE_NAME = "GoodWe"
ANCHOR_ENTITY_NAME = "P1 sample age"
def _check_anchor() -> None:
"""Is the MODEL right? Nothing about the shipped code is asked here."""
got = compose(ANCHOR_DEVICE_NAME, ANCHOR_ENTITY_NAME)
if got != ANCHOR_ID:
raise Refused(
f"the entity-id model composes {got!r} where app/mqtt.py records "
f"{ANCHOR_ID!r} as verified against the live system on 2026-08-26. "
"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.
⚠️ 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.
"""
out = set()
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.add(line)
if not out:
raise Refused(f"{path}: no entity ids in it")
return out
# --------------------------------------------------------------------------- #
# 4. ONE CONTROLLER START, OBSERVED
# --------------------------------------------------------------------------- #
class Hass:
"""No I/O. `Controller.__init__` performs none; this only has to exist."""
async def number(self, entity, invert=False):
return None
async def limits(self, entity):
return (None, None, None)
async def set_number(self, entity, value):
return False
async def call(self, domain, service, data):
return False
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Snapshot:
"""What one `Controller` start over one state file looks like."""
def __init__(self, label, opts, store, controller):
self.label = label
self.opts = opts
self.store = store
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.latched = {inv.name: bool(inv.sign_wd.tripped)
for inv in controller.inverters}
self.latch_reason = {inv.name: inv.sign_wd.reason
for inv in controller.inverters}
self.flat_tripped = bool(store.data.get("sign_tripped", False))
self.history = {}
for m in controller.maints:
self.history[m.name] = {k: m.store.get(k) for k in MAINT_UNIT_KEYS}
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()`.
self.stranded_at_default = []
@property
def unit_keys(self):
return sorted(k for k in self.data if k.startswith("unit/"))
def machine_reads(controller, store) -> set:
"""Every store key the CONSTRUCTED machines read, measured not transcribed.
⚠️ THE PROBE IS THE SHIPPED CONSTRUCTOR, RUN AGAIN. `Inverter.__init__` is
the code that restores a unit's SAFETY-02 and SAFETY-42 latches, so building
one more `Inverter` per roster entry against the post-migration store
replays EXACTLY the reads a restart performs - including any key this file
has never heard of. Writing out the key names here instead would be a second
transcription of `UNIT_STORE_KEYS`, and the copy that drifts is always the
one that reads (store.py, `unit_key()`).
The maintenance half iterates `MAINT_UNIT_KEYS`, which is the same tuple
`migrate_maint_keys()` moves and `maintenance.UnitHistory` indexes - so a
key renamed there cannot silently stop being probed here either.
"""
store.data.reads.clear()
for inv in controller.inverters:
Inverter(inv.name, store=store)
for m in controller.maints:
for key in MAINT_UNIT_KEYS:
m.store.get(key)
m.store.get_time(key)
seen = set(store.data.reads)
store.data.reads.clear()
return seen
def snapshot(label: str, opts: dict, state_path: str, tmpdir: str) -> Snapshot:
store = RehearsalStore(_scratch_copy(state_path, tmpdir, label))
controller = Controller(dict(opts), Hass(), store, Pub(), stop=None)
return Snapshot(label, opts, store, controller)
def snapshot_over(label: str, opts: dict, prior: Snapshot) -> Snapshot:
"""A second start over the store the previous start LEFT BEHIND.
⚠️ THE SAME FILE, NOT A SECOND COPY OF THE ORIGINAL. This is the sequence a
real box performs: the add-on restarts against `/data/state.json` as its own
previous run wrote it.
"""
store = RehearsalStore(prior.store.path)
controller = Controller(dict(opts), Hass(), store, Pub(), stop=None)
return Snapshot(label, opts, store, controller)
# --------------------------------------------------------------------------- #
# 5. THE DIFF
# --------------------------------------------------------------------------- #
class Finding:
def __init__(self, kind, text):
self.kind = kind
self.text = text
def __str__(self):
return f"{self.kind}: {self.text}"
def diff(before: Snapshot, after: Snapshot) -> list:
"""Everything that changed, and which of those changes is a defect."""
findings = []
# -- store keys ---------------------------------------------------------
gone = sorted(set(before.data) - set(after.data))
for key in gone:
findings.append(Finding(
"VANISHED STORE KEY",
f"{key} = {before.data[key]!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
# the migration's own read-back - and that no machine on the new roster will
# ever look under. `MigrationReport.misdirected` says the same thing about
# the keys the migration itself wrote; this says it about the WHOLE FILE,
# including keys written by a version that is no longer installed.
#
# ⚠️ ONLY WHERE THE VALUE IS NOT THE DEFAULT, WHICH IS `MigrationReport`'s
# OWN DISTINCTION AND NOT A SOFTENING OF THIS ONE. A stranded
# `sign_tripped = False` has lost the DEFAULT - the same statement "absent"
# already makes - so nothing is gone; a stranded `sign_tripped = True` has
# lost A LATCH. Reporting the first would make this tool red on a FRESH
# INSTALL, whose migration writes both defaults under `inverter-a` before
# any roster exists, and an alarm that fires on the harmless case is an
# alarm an operator learns to dismiss before the real one arrives - which is
# the argument `MigrationReport.lost` already carries in store.py.
for key in sorted(after.data):
if not key.startswith("unit/") or key in after.machine_read:
continue
value = after.data[key]
bare = key.rsplit("/", 1)[-1]
if value in (None, DEFAULTS.get(bare)):
after.stranded_at_default.append(key)
continue
findings.append(Finding(
"STRANDED STORE KEY",
f"{key} = {value!r} is in the store and NO machine on "
f"the roster {after.units!r} read it during a full start. Its "
f"value is not information."))
# -- the SAFETY-02 latch, by name, because SAFETY-44 asks for it by name --
for key in ("sign_tripped", "sign_reason"):
findings.extend(_latch_finding(key, before, after))
if before.flat_tripped and not any(after.latched.values()):
findings.append(Finding(
"LATCH CAME UP DISARMED",
"the state file carries a LATCHED SAFETY-02 sign-disagreement "
"failsafe under the pre-DEV-02 flat key `sign_tripped`, and NOT "
f"ONE inverter on the roster {after.units!r} came up tripped. "
"SAFETY-02: a latch that a restart clears is not a latch."))
# -- maintenance history -------------------------------------------------
findings.extend(_history_findings(before, after))
# -- entity ids ----------------------------------------------------------
orphans = sorted(set(before.entities) - set(after.entities))
for eid in orphans:
findings.append(Finding(
"ORPHANED ENTITY",
f"{eid} (unique_id {before.entities[eid]}) is announced before the "
"transition and NOT afterwards. Home Assistant does not delete it: "
"its history stops, and every automation whose trigger names it "
"silently stops firing - without an error."))
return findings
def _latch_finding(key, before, after):
"""Did the two SAFETY-44 keys land somewhere a machine reads?
⚠️ THE FIRST ROSTER ENTRY, AND ONLY THE FIRST. The flat pair belongs to
whichever inverter the single-inverter install was driving, which is
`inverters[0]` - that is what `migrate_maint_keys()` targets for the history
and what `Controller._grade_store_migration()` re-arms. Asking whether the
flat latch reached inverter B's namespace would report a finding on a
CORRECT migration, and an instrument that is red on the right answer is
worth nothing when it is red on the wrong one.
"""
out = []
if not after.units or key not in UNIT_STORE_KEYS:
return out
unit = after.units[0]
target = unit_key(unit, key)
was = before.data.get(key)
if target not in after.data and was not in (None, DEFAULTS.get(key)):
out.append(Finding(
"LATCH KEY NOT UNDER A ROSTER NAME",
f"the flat `{key}` = {was!r} did not reach {target}, which is the "
f"key `Inverter({unit!r})` restores from"))
elif (target in after.data and target not in after.machine_read
and after.data[target] not in (None, DEFAULTS.get(key))):
out.append(Finding(
"LATCH KEY NOT READ BY ANY MACHINE",
f"{target} = {after.data[target]!r} exists but no machine on the "
f"roster {after.units!r} read it during a full start"))
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."""
out = []
announced_before = set(before.entities)
announced_after = set(after.entities)
for eid in sorted(announced_before - recorded):
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):
out.append(Finding(
"ORPHANED ENTITY (against the recorded 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:
announced_before = set(before.entities)
foreign = sorted(recorded - 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 "
f"add-on in the BEFORE shape")
print(f" {len(foreign)} on the list 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:")
for eid in foreign[:60]:
print(f" {eid}")
if len(foreign) > 60:
print(f" ... and {len(foreign) - 60} more")
def _history_findings(before, after):
"""MAINT-05 moved this once. A second move must not drop it."""
out = []
if not before.history or not after.history:
return out
was = list(before.history.values())[0]
now = list(after.history.values())[0]
inheritor = list(after.history)[0]
for key in ("phase", "last_completed"):
old, new = was.get(key), now.get(key)
if old in (None, DEFAULTS.get(key)):
continue
if old != new:
out.append(Finding(
"MAINTENANCE HISTORY LOST",
f"`{key}` read {old!r} through the first pack's own history "
f"before the transition and reads {new!r} through "
f"{inheritor!r}'s afterwards"))
return out
# --------------------------------------------------------------------------- #
# 6. THE REPORT
# --------------------------------------------------------------------------- #
RETREAT_WINDOW_COMPLETION = "2026-09-20T18:00:00+00:00"
def _recalibrate_while_retreated(retreated: Snapshot) -> str:
"""A maintenance cycle finishes while the install is rolled back to flat.
⚠️ WRITTEN THROUGH THE SHIPPED WRITER. `Maintenance.store` is a
`maintenance.UnitHistory`, which is the one object that decides which key a
pack's history lands under and whether the flat rollback view is mirrored.
A raw `store.set("last_completed", ...)` here would be this file's own
opinion about where a completion goes, and the round trip would then be
grading that opinion instead of the add-on.
"""
store = RehearsalStore(retreated.store.path)
controller = Controller(dict(retreated.opts), Hass(), store, Pub(), stop=None)
controller.maints[0].store.set("last_completed", RETREAT_WINDOW_COMPLETION)
return RETREAT_WINDOW_COMPLETION
def _round_trip_findings(completed: str, readvanced: Snapshot) -> list:
"""Can the re-advanced roster still see work done during the retreat?"""
out = []
if not readvanced.history:
return out
inheritor = list(readvanced.history)[0]
seen = readvanced.history[inheritor].get("last_completed")
if seen != completed:
out.append(Finding(
"RETREAT-WINDOW WORK DISCARDED",
f"a maintenance cycle completed at {completed} while the install "
f"was rolled back to the flat config, and after re-advancing to "
f"the roster {inheritor!r} reads `last_completed` = {seen!r}. The "
"pack will be recalibrated again on a schedule that does not know "
"it already was."))
return out
def report_transition(name, before, after, findings) -> None:
print()
print("=" * 78)
print(f" {name}")
print("=" * 78)
print(f" roster before : {before.units}")
print(f" roster after : {after.units}")
print(f" DEV-02 migration : {after.store_migration.explain()}")
print(f" MAINT-05 migration: {after.maint_migration.explain()}")
print()
print(" -- STORE KEYS ----------------------------------------------------")
keys = sorted(set(before.data) | set(after.data))
width = max([len(k) for k in keys] + [10])
print(f" {'key'.ljust(width)} {'before':<26} {'after':<26} status")
for key in keys:
inb, ina = key in before.data, key in after.data
b = repr(before.data[key]) if inb else "-"
a = repr(after.data[key]) if ina else "-"
if inb and not ina:
status = "VANISHED"
elif ina and not inb:
status = "appeared"
elif before.data[key] != after.data[key]:
status = "changed"
else:
status = ""
if key.startswith("unit/") and ina and key not in after.machine_read:
status = (status + " " if status else "") + "STRANDED-nothing-reads-it"
print(f" {key.ljust(width)} {b[:26]:<26} {a[:26]:<26} {status}")
print()
print(" keys the AFTER roster's machines actually read, measured by "
"replaying")
print(" the shipped constructors against the post-transition store:")
for key in sorted(after.machine_read):
mark = " " if key in after.data else " (absent, defaulted) "
print(f" {key}{mark}")
if after.stranded_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:")
for key in after.stranded_at_default:
print(f" {key} = {after.data[key]!r}")
print()
print(" -- MAINTENANCE HISTORY (MAINT-05: a second move must not drop it) -")
for label, hist in (("before", before.history), ("after", after.history)):
for unit, vals in hist.items():
print(f" {label:<7} {unit:<12} "
+ " ".join(f"{k}={vals.get(k)!r}" for k in MAINT_UNIT_KEYS))
print()
print(" -- SAFETY-02 LATCH (SAFETY-44 asks about these two keys by name) -")
print(f" flat `sign_tripped` in the file : {before.flat_tripped}")
print(f" flat `sign_reason` in the file : "
f"{before.data.get('sign_reason')!r}")
print(f" before: which units came up latched : "
f"{[u for u, v in before.latched.items() if v] or 'none'}")
print(f" after : which units came up latched : "
f"{[u for u, v in after.latched.items() if v] or 'none'}")
for unit in after.units:
target = unit_key(unit, "sign_tripped")
print(f" {target} = {after.data.get(target, '<absent>')!r} "
f"(read by a machine: {target in after.machine_read})")
print()
print(" -- ENTITY IDS ----------------------------------------------------")
allids = sorted(set(before.entities) | set(after.entities))
for eid in allids:
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" {len(before.entities)} announced before, "
f"{len(after.entities)} after")
print()
print(" -- FINDINGS ------------------------------------------------------")
if not findings:
print(" none: nothing vanished, nothing stranded, nothing orphaned, "
"no history lost")
for f in findings:
print(f" ⚠️ {f}")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--state", required=True,
help="path to a COPY of /data/state.json. Never written to.")
ap.add_argument("--before", required=True,
help="the options in force now (flat single-inverter)")
ap.add_argument("--after", required=True,
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("--paths", default="both", choices=("staged", "same-step",
"both"),
help="staged: the box runs the BEFORE config once, then "
"restarts on AFTER. same-step: the roster is declared "
"in the SAME step as the upgrade, so the BEFORE "
"controller never runs against this store. "
"Default: both.")
args = ap.parse_args(argv)
try:
_check_anchor()
before_opts = load_options(args.before)
after_opts = load_options(args.after)
if not Path(args.state).is_file():
raise Refused(f"{args.state}: not a file")
except Refused as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
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}")
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)
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)")
findings += f
if args.paths in ("same-step", "both"):
# ⚠️ SAFETY-44's EXACT PATH. A store that ran pre-DEV-02 flat,
# upgraded straight to 0.7.0+, AND declared a multi-inverter roster
# in the same step - so nothing ever ran the flat config under a
# 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)
report_transition(
"SAME-STEP (SAFETY-44): a pre-DEV-02 flat store meets a "
"declared roster on its FIRST start under this version",
b2, a2, f2)
findings += f2
if args.reverse or args.round_trip:
r1 = snapshot("retreat-before", after_opts, args.state, tmpdir)
r2 = snapshot_over("retreat-after", before_opts, r1)
fr = diff(r1, r2)
report_transition(
"THE RETREAT: roster -> flat, which is the back-out if "
"commissioning B goes wrong", r1, r2, fr)
findings += fr
if args.round_trip:
# ⚠️ THE RETREAT WINDOW HAS TO CONTAIN WORK, OR THIS LEG IS HABIT 4.
# A round trip over a store that nothing changed in between compares
# a structure to itself and reports "clean" whatever the code does.
# So a maintenance cycle COMPLETES while the install is retreated -
# written through `maintenance.UnitHistory`, which is the shipped
# writer, not through a raw `store.set()` - and the question this
# leg asks is whether the pack can still see it after re-advancing.
q1 = snapshot("roundtrip-roster", after_opts, args.state, tmpdir)
q2 = snapshot_over("roundtrip-retreated", before_opts, q1)
worked = _recalibrate_while_retreated(q2)
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",
q2, q3, fq)
findings += fq
except Refused as err:
print(f"REFUSED: {err}", file=sys.stderr)
return EXIT_COULD_NOT_RUN
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
print()
print("=" * 78)
if findings:
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")
print("=" * 78)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())