da20722e76
gates / gates (push) Successful in 27s
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
143 lines
6.7 KiB
Python
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()
|