#!/usr/bin/env python3 """Phase 3 leg B1 — THE UNATTENDED HOLD, the measurement `09` §3b Q4 has never had. The 2026-09-21 night could not produce one: the only failing edge available was `vikunja -> alpine:3.20`, and the within-a-major rule CORRECTLY refused to attempt it. The rule that makes automatic updates safe is the same rule that refuses the obvious way to break one. So this leg builds the edge that rule CANNOT filter out: localhost:5000/drill/glance:1.0.0 the real glance image, retagged — it serves localhost:5000/drill/glance:1.0.1 starts, stays up, and NEVER SERVES — health fails Same repository, same major, plain version tags. `stacks.CompareImageRefs` orders them (proven by run, not by reading, in 09-image-store.txt), so the caller WILL press it — and the health wait in phase `verifying` must then hold the app. Nobody presses anything: `unattended-caller.py` from `audits/update-arc-gaps-2026-09-21/` is used VERBATIM. It is evidence, not product; it presses the same guarded Update a person presses. THE HELD APP IS THEN LEFT ALONE UNTIL PHASE 4. The morning-after look is the measurement, not this. """ import json, os, subprocess, sys, time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import walk as w # noqa: E402 APP = "glance" SUB = "dashboard" GOOD = "localhost:5000/drill/glance:1.0.0" BAD = "localhost:5000/drill/glance:1.0.1" OUT = os.path.join(HERE, "bad-days", "B1-unattended-hold") CALLER = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/unattended-caller.py" def cur_image(): return w.guest( f"grep -E '^\\s+image:' /opt/docker/stacks/{APP}/docker-compose.yml | sed 's/^ *//'").strip() def main(): os.makedirs(OUT, exist_ok=True) w.login() w.say("==== B1: the UNATTENDED HOLD") # 0. the box must be clean of other behind-edges, or the caller would press them too _, d = w.ctl("GET", "/api/stacks") ss = (d.get("data") or []) behind = [] for st in ss: if not st.get("deployed"): continue inst = {k: (v.get("ref") if isinstance(v, dict) else v) for k, v in ((st.get("app_config") or {}).get("installed_images") or {}).items()} cat = st.get("catalog_images") or {} if inst and cat and inst != cat: behind.append((st["name"], inst, cat)) w.say(f" [0] deployed apps not level with the catalog BEFORE the leg: " f"{[b[0] for b in behind]}") open(f"{OUT}/00-precondition.txt", "w").write(json.dumps(behind, indent=2, ensure_ascii=False)) # THE CALLER PRESSES EVERY BEHIND, WITHIN-A-MAJOR APP IT FINDS — that is the whole point of it, # and it means any OTHER app left behind by Phase 1 would be swept into this leg and muddy it. # Those are all throwaways, so they are removed through the product first. Recorded, because a # precondition arranged silently is a precondition nobody can check. swept = [] for name, inst, cat in behind: if name == APP: continue w.say(f" [0] removing {name} so the caller has only one app to react to " f"(installed={inst} catalog={cat})") w.remove(name) swept.append(name) if swept: w.say(f" [0] swept before the leg: {swept}") open(f"{OUT}/00-swept.txt", "w").write(json.dumps(swept, indent=2)) # 1. put glance on the GOOD drill image, from scratch st = w.stack(APP) if st.get("deployed"): w.say(" [1] removing the existing glance so it is redeployed from the drill image store") w.remove(APP) if w.drill_bump(APP, "glanceapp/glance:v0.8.5", GOOD) is None: # the drill template may already carry a previous drill ref w.say(" [1] template did not carry the live pin; trying the previous drill ref") for prev in ("glanceapp/glance:v0.8.6", BAD, "localhost:5000/drill/glance:1.0.2"): if w.drill_bump(APP, prev, GOOD) is not None: break w.sync_rescan() if not w.deploy(APP, SUB): w.say(" [1] glance never came up on the GOOD drill image — B1 cannot run") return w.say(f" [1] live compose now: {cur_image()}") open(f"{OUT}/01-deployed-on-good-image.txt", "w").write( cur_image() + "\n" + json.dumps(w.observables(APP), indent=2, ensure_ascii=False)) # 2. a fresh copy, so the update leans on it rather than making one w.backup_now(APP) # 3. the drill catalog publishes the version that starts and never serves h = w.drill_bump(APP, GOOD, BAD) w.sync_rescan() b = w.badges(APP) w.say(f" [3] badge HU: {b['hu']}") w.say(f" [3] badge EN: {b['en']}") json.dump({"drill_commit": h, "badges": b}, open(f"{OUT}/02-bad-edge-published.json", "w"), indent=2, ensure_ascii=False) # 4. NOBODY PRESSES ANYTHING — the caller, verbatim w.say(" [4] running unattended-caller.py, 3 passes, 90 s apart — nobody presses anything") t0 = time.time() with open(f"{OUT}/03-unattended-caller.log", "w") as fh: p = subprocess.run([sys.executable, CALLER, "--passes", "3", "--every", "90"], stdout=fh, stderr=subprocess.STDOUT, timeout=2400) w.say(f" [4] caller exited rc={p.returncode} after {round(time.time()-t0,1)}s") for line in open(f"{OUT}/03-unattended-caller.log"): if any(k in line for k in ("BEHIND", "REFUSED", "ENDED", "SKIP", "summary", "phase=")): w.say(" " + line.rstrip()) # 5. what the box says now — the state, and the household's sentences in BOTH languages st = w.stack(APP) state = {"state": st.get("state"), "updating": st.get("updating"), "update_phase": st.get("update_phase"), "update_phase_label": st.get("update_phase_label"), "update_error": st.get("update_error"), "hold_reason": st.get("hold_reason"), "observables": w.observables(APP)} json.dump(state, open(f"{OUT}/04-state-after.json", "w"), indent=2, ensure_ascii=False) w.say(f" [5] state={state['state']} phase={state['update_phase']} " f"error={state['update_error']!r} hold={state['hold_reason']!r}") w.say(f" [5] pinned={state['observables']['pinned_images']}") w.say(f" [5] installed={state['observables']['installed_images']}") w.say(f" [5] inspect={state['observables']['docker_inspect']}") for lang, sfx in (("hu", ""), ("en", "?lang=en")): open(f"{OUT}/05-app-page-{lang}.html", "w").write(w.page(f"/apps/{APP}{sfx}")) open(f"{OUT}/06-app-logs.txt", "w").write(w.app_logs(APP, 300)) open(f"{OUT}/log.txt", "w").write("\n".join(w.LOG) + "\n") w.say(" [6] evidence written. THE APP IS LEFT HELD ON PURPOSE — Phase 4 is the measurement.") if __name__ == "__main__": main()