Files
felhom.eu/documentation/audits/update-night-2026-09-21/phase3_b1.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

143 lines
6.7 KiB
Python

#!/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()