Files
felhom.eu/documentation/audits/v0262-live-2026-09-22/liveBC.py
T
admin 22439b0e43
gates / gates (push) Successful in 30s
v0.262.0 + v0.262.1 live on 9202: four scenarios proven, R-630/R-633/R-621/R-614 closed
A (R-630, the P1): paperless-ngx, same app same button - failed at +313.0s with the app stopped
under v0.261.0, done at +53.4s now, with no "no probe container" warning because the explicit
healthcheck.container resolved the target.

B (R-633): the busy guard fires - RemoveStack REFUSED (busy): a backup or restore is running - and
the live proof caught it answering HTTP 500, because router.go maps remove errors by grepping the
error TEXT. v0.262.1 makes it a typed error and a 409; re-proven live.

C (R-634 half): a half-state with app.yaml on disk and deployed=false answered 200, leftovers NONE.
Under v0.261.0 the same call said "not deployed".

F (R-614): phase done before the remove, no phase at all after redeploying the same name.

Also: the first B run proved NOTHING and nearly went down as a pass - the refusal came from the
pre-existing "still running" check, not the new guard. Recorded.

09 6.1 and 8.8, the capability map, and STATUS updated. R-625 and R-634's mechanism are named as
owed, not half-done. Register 325.

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

86 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""B (R-633) and C (R-634) live on 9202, against v0.262.0."""
import json, os, sys, time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "update-night-2026-09-21"))
import walk as w # noqa: E402
OUT = json.load(open(os.path.join(HERE, "live262.json"))) if os.path.exists(
os.path.join(HERE, "live262.json")) else {}
def save(tag, rec):
OUT[tag] = rec
json.dump(OUT, open(os.path.join(HERE, "live262.json"), "w"), ensure_ascii=False, indent=2)
print("\n== " + tag + "\n" + json.dumps(rec, ensure_ascii=False, indent=2)[:1500], flush=True)
def B():
"""A remove sent DURING a restore must be refused; a remove after it must be VERIFIED."""
# gokapi is crash-looping from this morning's R-633 artefact — its config volume was removed, so
# its binary can never start and the stack never settles. A subject that cannot reach a steady
# state proves nothing about a guard that fires between steady states. privatebin is light,
# reliable and was walked all night.
app, sub = "privatebin", "paste"
rec = {"scenario": "B (R-633/R-626)", "app": app}
w.deploy(app, sub)
w.wait_app(sub, "/", tries=40)
w.backup_now(app)
snaps = w.snapshots(app)
rec["snapshots"] = len(snaps)
# start a restore and, while it runs, ask to remove
r = w.restore(app)
rec["restore_started"] = str(r)[:200]
time.sleep(2)
code, d = w.ctl("POST", f"/api/stacks/{app}/remove", {"remove_hdd_data": False, "remove_backups": False})
rec["remove_during_restore"] = {"http": code, "answer": d}
rec["refused_as_expected"] = code not in ("200", "202")
# let the restore settle, then remove properly
for _ in range(60):
st = w.stack(app)
if st.get("state") in ("running", "unhealthy", "stopped", "not_deployed"):
break
time.sleep(5)
time.sleep(10)
code, d = w.ctl("POST", f"/api/stacks/{app}/stop")
time.sleep(8)
code, d = w.ctl("POST", f"/api/stacks/{app}/remove", {"remove_hdd_data": True, "remove_backups": True})
rec["remove_after_restore"] = {"http": code, "verified": (d.get("data") or {}).get("verified"),
"reappeared": (d.get("data") or {}).get("reappeared_removed")}
time.sleep(30)
rec["containers_60s_later"] = w.guest(
f"docker ps -a --filter label=com.docker.compose.project={app} --format '{{{{.Names}}}}'").strip()
save("B", rec)
def C():
"""A half-state — app.yaml on disk, deployed=false, no containers — must be REMOVABLE."""
app = "sparkyfitness"
rec = {"scenario": "C (R-634)", "app": app}
# build the exact measured shape by hand on the scratch guest: the stack dir with an app.yaml
# and no containers, which is what two failed deploys left behind twice.
rec["setup"] = w.guest(f'''set -e
mkdir -p /opt/docker/stacks/{app}
cp /var/lib/docker/volumes/felhom-controller-data/_data/data/catalog-cache/templates/{app}/docker-compose.yml /opt/docker/stacks/{app}/ 2>/dev/null || true
printf 'desired_state: stopped\\ninstalled_images:\\n' > /opt/docker/stacks/{app}/app.yaml
ls -la /opt/docker/stacks/{app}/''').strip()[-300:]
w.ctl("POST", "/api/stacks/rescan")
time.sleep(6)
st = w.stack(app)
rec["before"] = {"deployed": st.get("deployed"), "state": st.get("state")}
code, d = w.ctl("POST", f"/api/stacks/{app}/remove", {"remove_hdd_data": False, "remove_backups": True})
rec["remove"] = {"http": code, "answer": str(d)[:300]}
rec["accepted"] = code == "200"
time.sleep(5)
rec["leftovers"] = w.guest(f"ls /opt/docker/stacks/{app}/app.yaml 2>/dev/null || echo NONE").strip()
save("C", rec)
if __name__ == "__main__":
w.login()
for fn in (B, C):
try:
fn()
except Exception as e:
save(fn.__name__ + "-ERROR", {"error": f"{type(e).__name__}: {e}"})