Files
felhom.eu/documentation/audits/update-night-2026-09-21/phase3_b5.py
T
admin da20722e76
gates / gates (push) Successful in 27s
Update night 2026-09-21: Phase 0 and Phase 1 evidence, the drill method, and two instrument fixes
INTERIM CHECKPOINT — evidence off the machine at the end of the phase that produced it (R-320),
not at the end of the session. Phases 2-5 follow in a later commit.

Phase 0, all three mechanisms proven with their controls:
- the fleet floor to 0.261.0 with its declared MinAgent — both demo boxes in 13 s, the hub
  logging `managed floor SERVED ... from declared (golden 0.258.0)`.
- a PRIVATE DRILL CATALOG (admin/app-catalog-drill), so that broken, dummy, cross-repo and
  engine-major edges can be measured without the live catalog ever carrying one. Positive
  control quoted, and two negative controls: the live catalog's main and both real boxes'
  caches unchanged.
- a throwaway image store on the scratch guest, which is what makes an UNATTENDED HOLD
  measurable at all: an edge that PASSES the within-a-major test and still fails.
  CompareImageRefs was proven to order host:port/ references by RUNNING it (4 positive cases
  + 1 negative control), not by reading it.

Phase 1: real within-a-major upstream edges walked on guest 9202 through the product's own
guarded Update, each app seeded and read back through its OWN front door (R-156), with a
per-edge verdict record in 09's shape. `inconclusive` is never collapsed into `failed`.

TWO INSTRUMENT FIXES, both in this repo's own evidence code:
- 00-api-recipe.md said the app page is /app/<n>; it is /apps/<n>, and every call it described
  404s. Corrected, with the session-expiry note that cost the same time.
- unattended-caller.py's follow() read update_phase/updating off the API ENVELOPE, so both were
  always None and EVERY followed update ran to its 900 s timeout and was then recorded
  `timeout` and never-press-again. Fixed before B1 relied on it. R-623.

No controller, agent or hub code was written. The live catalog carries no broken reference.

Gates: repo_gates.py --fast — all 15 OK, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 21:17:46 +02:00

201 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Phase 3 leg B5 — a power cut in `backing-up`, and in `safety-dump`.
R-520 cut in `pulling` (nothing had run). R-610 cut after `starting` (the migration had run). The
two phases NOBODY has cut in are the two EARLY ones, and they are the ones that touch the
customer's copy rather than their data:
backing-up `RunAppBackupNow` is making the copy the update will lean on
safety-dump `WriteUpdateSafetyDump` is writing R-361's undo copy, BEFORE the pin moves
Source says both should end with *nothing moved, the journal entry dropped, and the interrupted
sentence shown*. THE CLAIM THIS LEG EXISTS TO TEST is narrower and nastier than that: **a backup
artefact that was half-written must not be left looking whole**, because the next update's
precondition will believe it.
INSTRUMENT LIMITS, STATED UP FRONT because they bound every claim below:
* the poll is 200 ms and `pct stop` returns in 3–4 s, so the phase at the DECISION is observed
and the phase at the FREEZE is inferred. Said every time, never glossed (R-520's own lesson).
* `backing-up` only happens when no tier holds a copy younger than `update.backup_max_age`, so
this leg lowers that knob — an operator-owned setting on `controller.yaml`, restored at teardown.
* `pct mount` shows an EMPTY STUB for the guest's inner mounts, so every post-crash read is taken
from the BOOTED guest, or with `find` over the whole rootfs and a positive control. An empty
directory is not evidence of an absent file.
"""
import json, os, re, subprocess, sys, time
from datetime import datetime
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import walk as w # noqa: E402
from fixtures import FIXTURES # noqa: E402
from phase3_legs import out, sentences # noqa: E402
V = "/var/lib/docker/volumes/felhom-controller-data/_data"
def hostsh(cmd, timeout=300):
return (w.sh(["ssh", "-o", "ConnectTimeout=20", w.HP, cmd], timeout=timeout).stdout or "")
def set_knob(key, value):
"""Set one operator-owned knob in controller.yaml and restart. Restored at teardown."""
scr = f"""
python3 - <<'PY'
import re
p="{V}/controller.yaml"
s=open(p).read()
if re.search(r'^update:', s, re.M):
if re.search(r'^(update:\\n(?: .*\\n)*?) {key}:.*$', s, re.M):
s=re.sub(r'^(update:\\n(?: .*\\n)*?) {key}:.*$', r'\\g<1> {key}: {value}', s, count=1, flags=re.M)
else:
s=re.sub(r'^update:\\n', 'update:\\n {key}: {value}\\n', s, count=1, flags=re.M)
else:
s += "\\nupdate:\\n {key}: {value}\\n"
open(p,'w').write(s)
PY
sed -n '/^update:/,/^[a-z]/p' {V}/controller.yaml
systemctl restart felhom-controller-bootstrap.service
sleep 22
docker ps --filter name=felhom-controller --format '{{{{.Status}}}}'
"""
o = w.guest(scr, timeout=400)
w.say(f" [knob] {key}={value} :: " + " | ".join(x for x in o.split("\n") if x.strip())[:260])
w.login()
return o
def cut_during(app, want_phase, poll=0.2, cap_s=300):
"""Press Update, poll at 200 ms, and pull the plug the moment the wanted phase is observed."""
code, body = w.ctl("POST", f"/api/stacks/{app}/update")
w.say(f" [cut] Update -> {code} {str(body)[:120]}")
if code not in ("202", "200"):
return {"pressed": False, "http": code, "body": body}
seen, t0, decided = [], time.time(), None
while time.time() - t0 < cap_s:
st = w.stack(app)
ph = st.get("update_phase")
if not seen or seen[-1]["phase"] != ph:
seen.append({"t": round(time.time() - t0, 3), "phase": ph})
w.say(f" +{seen[-1]['t']:>7.3f}s phase={ph}")
if ph == want_phase:
decided = datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
w.say(f" [cut] phase {want_phase!r} OBSERVED at {decided} — pulling the plug NOW")
t1 = time.time()
r = hostsh("pct stop 9202", timeout=180)
w.say(f" [cut] `pct stop 9202` returned after {round(time.time()-t1,2)}s :: {r.strip()[:120]}")
return {"pressed": True, "phases_seen": seen, "cut_decided_at": decided,
"pct_stop_returned_after_s": round(time.time() - t1, 2),
"instrument_limit": "the phase at the DECISION is observed; the phase at the "
"FREEZE is inferred — pct stop is not instantaneous"}
if not st.get("updating") and ph in ("done", "failed"):
w.say(f" [cut] the update ENDED at phase {ph} before {want_phase!r} was ever seen")
return {"pressed": True, "phases_seen": seen, "cut_decided_at": None,
"missed": want_phase, "ended_phase": ph}
time.sleep(poll)
return {"pressed": True, "phases_seen": seen, "cut_decided_at": None, "timeout": True}
def boot_and_read(app):
hostsh("pct start 9202", timeout=300)
w.say(" [boot] guest 9202 starting")
for _ in range(90):
time.sleep(5)
o = hostsh("pct exec 9202 -- docker ps --filter name=felhom-controller "
"--format '{{.Status}}' 2>/dev/null")
if "Up" in o:
w.say(f" [boot] controller back: {o.strip()}")
break
time.sleep(20)
w.login()
recovery = w.guest(
"docker logs felhom-controller 2>&1 | grep -iE 'recover|interrupted|journal|pin .*back|"
f"resum' | tail -20; echo '--- journal file:'; ls -la {V}/data/update-journal.json 2>&1")
w.say(" [boot] recovery lines: " + " | ".join(
x for x in recovery.split("\n") if x.strip())[:500])
st = w.stack(app)
return {"recovery_lines": recovery, "state": st.get("state"),
"update_phase": st.get("update_phase"), "update_error": st.get("update_error"),
"hold_reason": st.get("hold_reason"), "observables": w.observables(app)}
def backup_artefacts(app):
"""Is a half-written backup artefact left LOOKING WHOLE? The question the leg exists for."""
return w.guest(f"""
echo "=== the app's own recovery unit + its db dumps, with sizes and times"
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -type f -printf '%TY-%Tm-%Td %TH:%TM %10s %p\\n' 2>/dev/null | sort | tail -25
echo "=== any temp/partial names left behind"
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' \\( -name '*.tmp' -o -name '*.part' -o -name '*partial*' -o -name '*.inprogress' \\) 2>/dev/null | head -10 || true
echo "=== the unit manifest, if there is one"
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -name 'manifest*.json' -exec sh -c 'echo "--- {{}}"; head -c 700 "{{}}"; echo' \\; 2>/dev/null | head -40
echo "=== ZERO-BYTE files under this app's backups (a half-write that still looks like a file)"
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -type f -size 0 2>/dev/null | head -10 || echo "(none)"
""", timeout=420)
def run(app, sub, phase, label):
d = out(f"B5-{label}")
w.say(f"==== B5: a power cut during `{phase}` on {app}")
rec = {"leg": f"B5-{label}", "app": app, "phase_targeted": phase}
# B5 needs a PENDING edge or there is nothing for the cut to interrupt. B4 leaves these apps
# level with the catalog, so publish one here — the same image under the next tag, so the pull
# is instant and the cut lands in the EARLY phases this leg is about rather than in `pulling`.
repo = {"privatebin": "paste", "bentopdf": "pdf"}.get(app)
if repo:
cur = None
for line in open(f"{w.DRILL}/templates/{app}/docker-compose.yml"):
if line.strip().startswith("image:"):
cur = line.strip().split("image:", 1)[1].strip()
break
nxt = f"localhost:5000/drill/{repo}:2.0.2"
if cur and cur != nxt:
w.drill_bump(app, cur, nxt)
w.sync_rescan()
st = w.stack(app)
w.say(f" [0] pending edge for the cut: installed="
f"{ {k:(v.get('ref') if isinstance(v,dict) else v) for k,v in ((st.get('app_config') or {}).get('installed_images') or {}).items()} } "
f"catalog={st.get('catalog_images')}")
before = w.observables(app)
rec["observables_before"] = before
rec["backup_artefacts_before"] = backup_artefacts(app)
open(f"{d}/00-backups-before.txt", "w").write(rec["backup_artefacts_before"])
w.say(f" [0] pinned before = {before['pinned_images']}")
rec["cut"] = cut_during(app, phase)
if not rec["cut"].get("cut_decided_at"):
w.say(" [!] the phase was never observed — the cut did NOT happen. Recorded as a MISS, "
"not as a pass.")
json.dump(rec, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False)
open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n")
return
rec["after_boot"] = boot_and_read(app)
rec["backup_artefacts_after"] = backup_artefacts(app)
open(f"{d}/01-backups-after.txt", "w").write(rec["backup_artefacts_after"])
rec["sentences"] = sentences(app)
w.say(f" [2] household sentence HU: {rec['sentences']['hu'][:3]}")
w.say(f" [2] household sentence EN: {rec['sentences']['en'][:3]}")
fx = FIXTURES.get(app)
if fx:
tok = fx.seed(w, sub, w.say)
rec["app_usable_after"] = bool(tok) and fx.verify(w, sub, tok, w.say)
w.say(f" [3] the app works and holds data after the cut: {rec['app_usable_after']}")
a, b = rec["observables_before"], rec["after_boot"]["observables"]
rec["pin_moved"] = a["pinned_images"] != b["pinned_images"]
w.say(f" [4] pinned after = {b['pinned_images']} (moved: {rec['pin_moved']})")
json.dump(rec, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False)
open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n")
if __name__ == "__main__":
w.login()
cmd = sys.argv[1]
if cmd == "knob":
set_knob(sys.argv[2], sys.argv[3])
else:
run(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])