#!/usr/bin/env python3 """proof1102.py — live proofs of R-1347, R-1348, R-1351, R-1339 and Traefik v3.7.17 on scratch 9202 (2026-11-02 night). Front doors only where a household has one (deploy, Update, the restore button, Start, Remove). The faults are real docker state, not switches: the undo copy loses its finished-marker (R-1347), and a created-only container holds the app's volume so the crash put-back (R-1348) cannot replace it. The cut is `docker kill` of the controller at the log line „the restore copy is recorded". Phases, in order: boot-before guest reboot on the controller as found; the FileBrowser lines of that boot switch load the local-only test image (never pushed), point the box at it and at the event sink boot-after guest reboot on the test image; the same lines; Traefik's and the file manager's state r1347 radicale: an update that fails with an undo copy cut off → HOLD; the household's restore lifts the hold and forgets the flag; 7 kill -9 → the crash-loop watcher stops the app (it watches again) r1348 the household's Start, then a restore cut at the copy's record, the volume held, the controller started → the put-back fails at start-up → the hold AND the operator event in the sink teardown blocker gone, app removed through the product, controller back on the image it had, yaml byte-identical Env: SC (0600 scratch with .ctlpw), EV (evidence dir). No secret is printed; the sink stores event bodies only.""" import json, os, subprocess, sys, threading, time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") os.environ.setdefault("TARGET", "9202") import box_walk as w import upgrade_fixtures_box as fb EV = os.environ.get("EV", ".") APP, SUB = "radicale", getattr(fb.FIXTURES["radicale"], "sub", "radicale") VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" YAML = f"{VOL}/controller.yaml" SAVE = f"{VOL}/controller.yaml.pre-proof1102" IMG_FOUND = "gitea.dooplex.hu/admin/felhom-controller:0.349.0-rc.1" IMG_TEST = "gitea.dooplex.hu/admin/felhom-controller:0.350.0-rc.1-sec1102fault" SINK_PORT = 18091 SINK_FILE = "/tmp/sec1102-sink.jsonl" BLOCKER = "sec1102-blocker" MARKER = 'felhom-undo-complete' DRILL = w.DRILL STATE = os.path.join(os.environ["SC"], ".proof-state.json") def say(*a): w.say(*a) def flush(name): with open(os.path.join(EV, f"{name}.log"), "a") as f: f.write("\n".join(w.LOG) + "\n") def guest(script, timeout=600): return w.guest(script, timeout=timeout) def ctl_log(since, rx): return guest(f"docker logs --since {since} felhom-controller 2>&1 | grep -E '{rx}' | grep -v DEBUG | cut -c1-260 | tail -40") def now(): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) def wait_login(tries=60): for _ in range(tries): time.sleep(5) try: w.login() return True except SystemExit: continue return False def boot_lines(tag): """Reboot the guest and read this boot's FileBrowser and first-boot lines from the controller's log.""" t0 = now() r = w.sh(w.ssh_args() + [f"pct reboot {w.GUEST}"], timeout=180) say(f" pct reboot {w.GUEST} rc={r.returncode}") time.sleep(20) ok = wait_login(40) say(f" login after the reboot: {ok}") time.sleep(45) # the base stack's round and the mounts sync img = guest("cat /etc/felhom-controller-image; docker ps --format '{{.Names}} {{.Image}} {{.Status}}' | grep -E 'felhom-controller|traefik|filebrowser|cloudflared'") say(f" images:\n{img}") lines = guest(f"docker logs --since {t0} felhom-controller 2>&1 | grep -E 'Conflict|first-boot|filebrowser compose exists|FileBrowser mounts synced|FileBrowser sync|not mounted|skipping its folder|could not leave|traefik' | grep -v DEBUG | cut -c1-240") say(f" [{tag}] FileBrowser lines of this boot:\n{lines}") say(f" [{tag}] Conflict lines: {lines.count('Conflict')}; first-boot WARN: {lines.count('first-boot base-stack')}; 'not mounted' lines: {lines.count('not mounted')}") def phase_boot_before(): w.login() boot_lines("before") def start_sink(): """The event sink: a tiny HTTP server in the guest, event bodies only. hub.enabled stays false, so nothing else moves. /tmp is a tmpfs here, so a guest reboot takes file and process away: it is written again each time.""" sink = f""" import http.server, json class H(http.server.BaseHTTPRequestHandler): def do_POST(self): n = int(self.headers.get('Content-Length') or 0); b = self.rfile.read(n) if self.path.endswith('/event'): open({SINK_FILE!r}, 'ab').write(b + b'\\n') self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(b'{{}}') def do_GET(self): self.send_response(404); self.end_headers() def log_message(self, *a): pass http.server.HTTPServer(('0.0.0.0', {SINK_PORT}), H).serve_forever() """ return guest(f"cat > /tmp/sec1102-sink.py <<'EOF'\n{sink}\nEOF\nrm -f {SINK_FILE}; pgrep -f 'sec1102-sin[k]' >/dev/null || (nohup python3 /tmp/sec1102-sink.py >/dev/null 2>&1 &)\nsleep 1; curl -s -o /dev/null -w '%{{http_code}}' -X POST http://127.0.0.1:{SINK_PORT}/ping; echo") def phase_switch(): say(guest("docker ps --format '{{.Names}} {{.Image}} {{.Status}}'; cat /etc/felhom-controller-image; " "python3 - <<'PY'\nimport json\ns=json.load(open('/var/lib/docker/volumes/felhom-controller-data/_data/data/settings.json'))\n" "print('holds as found:', sorted((s.get('restore_holds') or {}).keys()))\nPY")) tar = os.environ["IMG_TAR"] w.sh(["scp", "-q", tar, f"{w._T['ssh']}:/tmp/sec1102-ctl.tar.gz"], timeout=300) w.sh(w.ssh_args() + [f"pct push {w.GUEST} /tmp/sec1102-ctl.tar.gz /tmp/sec1102-ctl.tar.gz && rm -f /tmp/sec1102-ctl.tar.gz"], timeout=300) say(guest("zcat /tmp/sec1102-ctl.tar.gz | docker load | tail -1; rm -f /tmp/sec1102-ctl.tar.gz", timeout=400)) start_sink() ip = guest("ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1").strip() say(f" sink on {ip}:{SINK_PORT}") say(guest(f"""set -e test -f {SAVE} || cp -p {YAML} {SAVE} python3 - <<'PY' import re p = {YAML!r}; s = open(p).read() s = re.sub(r'(^hub:\\n\\s+api_key: )""', r'\\g<1>"sec1102-test"', s, count=1, flags=re.M) s = re.sub(r'(^hub:(?:\\n\\s+.*)*?\\n\\s+url: )""', r'\\g<1>"http://{ip}:{SINK_PORT}"', s, count=1, flags=re.M) open(p, 'w').write(s) PY sed -n '/^hub:/,/url:/p' {YAML} | sed -E 's/api_key: .*/api_key: (set)/' echo {IMG_TEST} > /etc/felhom-controller-image systemctl restart felhom-controller-bootstrap.service sleep 8; docker ps --format '{{{{.Names}}}} {{{{.Image}}}} {{{{.Status}}}}' | grep felhom-controller""")) say(f" login: {wait_login(30)}") say(guest("docker logs felhom-controller 2>&1 | grep -E 'Notifier enabled|Notifier disabled' | tail -2 | cut -c1-200; docker exec felhom-controller felhom-controller --version | cut -c1-120")) def phase_boot_stage(): """The race needs a boot where the sync RECREATES: both boots above found nothing changed, so no race (0 Conflict before too). A comment appended to FileBrowser's compose makes the file differ from what the sync renders, as after an update.""" w.login() # Stage 2 (STOP_FB=1): the file manager also stays down through the boot, so the base stack's bring-up starts it too: both paths. stop = "docker stop filebrowser >/dev/null; " if os.environ.get("STOP_FB") else "" say(guest(stop + "echo '# sec1102 stage' >> /opt/docker/stacks/filebrowser/docker-compose.yml; tail -1 /opt/docker/stacks/filebrowser/docker-compose.yml; cat /etc/felhom-controller-image")) boot_lines(os.environ.get("BOOT_TAG", "stage")) def phase_boot_after(): w.login() boot_lines("after") code, d = w.ctl("GET", "/api/health") say(f" /api/health {code}") def phase_r1347(): w.login() drill_imgs = w.images_of(open(f"{DRILL}/templates/{APP}/docker-compose.yml").read()) say(f" drill's {APP}: {drill_imgs}") svc, frm = next(iter(drill_imgs.items())) if w.stack(APP).get("deployed"): w.remove(APP) w.refresh_box_mirror() w.sync_rescan() assert w.deploy(APP, SUB), "the install did not complete" w.wait_app(SUB, "/", tries=60) fx = fb.FIXTURES[APP] tok = fx.seed(w, SUB, say) say(f" seeded: {bool(tok)}; read back: {tok is not None and fx.verify(w, SUB, tok, say)}") json.dump({"tok": tok, "generated": getattr(w, "GENERATED", {})}, open(STATE, "w")) os.chmod(STATE, 0o600) vols = guest(f"docker volume ls --format '{{{{.Name}}}}' | grep '^{APP}_'").split() say(f" volumes: {vols}") guest(f"docker rm -f {BLOCKER} 2>/dev/null") commit = w.drill_bump(APP, frm, "busybox:1.36.1") say(f" drill commit {commit}") say(f" box read TO after {w.sync_rescan(APP, 'busybox:1.36.1', tries=40, delay=10)} s") t0 = now() # The undo must fail for a hold to exist. A volume held by a created-only container does not stop it (tried first: the undo # empties the volume and pours), so the undo copy is cut off, the shape of a helper killed mid-copy: its finished-marker goes # while the update verifies, and the undo refuses to pour („NOTHING is put back", TestUndo_CutOffCopyIsRefusedBeforeAnythingMoves). def cut_copy(): for _ in range(120): time.sleep(3) names = guest(f"docker volume ls --format '{{{{.Name}}}}' | grep '^{APP}_.*\\.pre-update-'").split() if names: time.sleep(15) say(" the undo copy cut off: " + names[0] + " " + guest(f"docker run --rm -v {names[0]}:/c busybox:1.36.1 sh -c 'rm -f /c/{MARKER} && ls -a /c | tr \"\\n\" \" \"'")) return cutter = threading.Thread(target=cut_copy) cutter.start() u = w.press_update(APP, poll=2.0, cap_s=1500) cutter.join(30) say(f" update: accepted={u.get('accepted')} final={u.get('final_phase')} err={str(u.get('update_error'))[:200]}") time.sleep(10) st = w.stack(APP) say(f" after the update: state={st.get('state')} hold={st.get('hold_reason')!r} phase={st.get('update_phase')} key={st.get('update_error_key')}") say(" controller log:\n" + ctl_log(t0, f"update {APP}|{APP}: .*(HOLD|hold|undo)|restore hold|Failed to remove")) flush("r1347-hold") return phase_r1347b() def hold_code(): """The hold's own code, from the box's settings: the API's hold_reason is the household's sentence.""" return guest(f"python3 -c \"import json;s=json.load(open('{VOL}/data/settings.json'));print((s.get('restore_holds') or {{}}).get('{APP}',{{}}).get('reason',''))\"").strip() def phase_r1347b(): w.login() code = hold_code() say(f" the hold in the box's settings: {code!r}") if code != "update_failed": say(" STOP: no update hold — the stage did not work; nothing to prove") return 1 fx = fb.FIXTURES[APP] tok = None try: tok = (json.load(open(STATE)) or {}).get("tok") except (OSError, ValueError): pass t0 = now() res = w.restore(APP) say(f" restore: {json.dumps({k: res.get(k) for k in ('ok', 'snapshot_id', 'http', 'seconds', 'state_after', 'hold_after')})}") time.sleep(10) st = w.stack(APP) say(f" after the restore: state={st.get('state')} hold={st.get('hold_reason')!r} phase={st.get('update_phase')} update_error={st.get('update_error')!r} key={st.get('update_error_key')!r}") say(" controller log:\n" + ctl_log(t0, "update hold|R-1347|update-held|undo cop|restore completed")) say(f" the seed reads back: {tok is not None and fx.verify(w, SUB, tok, say)}") subprocess.run(["git", "-C", DRILL, "fetch", "-q", "live"], check=False) subprocess.run(["git", "-C", DRILL, "reset", "-q", "--hard", "live/main"], check=False) subprocess.run(["git", "-C", DRILL, "push", "-q", "-f", "origin", "main"], check=False) w.refresh_box_mirror() w.sync_rescan() # the consequence: the crash-loop watcher watches this app again (D53's recipe: seven kill -9, 15 s apart) t0 = now() for i in range(10): pid = guest(f"docker inspect -f '{{{{.State.Pid}}}}' {APP} 2>/dev/null").strip() if pid and pid != "0": guest(f"kill -9 {pid}") say(f" kill -9 #{i + 1} pid {pid}; state {w.stack(APP).get('state')} hold {w.stack(APP).get('hold_reason')!r}") if w.stack(APP).get("hold_reason"): break time.sleep(15) time.sleep(35) st = w.stack(APP) code = hold_code() say(f" after the kills: state={st.get('state')} hold code={code!r}") say(" controller log:\n" + ctl_log(t0, "crash_loop|STOPPING|unhealthy")) return 0 if code == "unhealthy_stop" else 1 def phase_r1348(): w.login() st0 = json.load(open(STATE)) if getattr(w, "GENERATED", None) is not None: w.GENERATED.update(st0.get("generated") or {}) st = w.stack(APP) say(f" before: state={st.get('state')} hold={st.get('hold_reason')!r}") if hold_code() == "unhealthy_stop": c, d = w.ctl("POST", f"/api/stacks/{APP}/start") # the household's Start lifts this one (D53) say(f" Start -> {c} {str(d)[:120]}") for _ in range(30): time.sleep(5) st = w.stack(APP) if st.get("state") == "running": break say(f" after Start: state={st.get('state')} hold={st.get('hold_reason')!r}") say(" sink answers: " + start_sink()) vols = guest(f"docker volume ls --format '{{{{.Name}}}}' | grep '^{APP}_'").split() # The cut, inside the guest so it has no ssh delay: at the log line, kill the controller, hold the volume, start again. cut = (f"timeout 400 sh -c \"docker logs -f --since 2s felhom-controller 2>&1 | grep -m1 'the restore copy is recorded' > /tmp/sec1102-cut.txt\"; " f"docker kill felhom-controller >/dev/null; docker create --name {BLOCKER} -v {vols[0]}:/x busybox:1.36.1 true >/dev/null; " f"sleep 2; docker start felhom-controller >/dev/null; cut -c1-200 /tmp/sec1102-cut.txt; date -u +%H:%M:%S; docker ps -a --format '{{{{.Names}}}} {{{{.Status}}}}' | grep -E 'felhom-controller|{BLOCKER}'") out = {} th = threading.Thread(target=lambda: out.update(cut=guest(cut, timeout=450))) t0 = now() th.start() time.sleep(4) code, snaps = w.ctl("GET", f"/api/backup/snapshots?stack={APP}") snap = w.snapshots(APP)[0] sid = snap.get("id") or snap.get("snapshot_id") or snap.get("short_id") sess = open(f"{w.SC}/sess{os.getpid()}.txt").read().strip() csrf = open(f"{w.SC}/csrf{os.getpid()}.txt").read().strip() r = w.sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", w.HOSTHDR, "-H", f"Cookie: {sess}", "-X", "POST", "--data-urlencode", f"_csrf={csrf}", "--data-urlencode", f"stack_name={APP}", "--data-urlencode", f"snapshot_id={sid}", f"{w.BASE}/backup/restore"], timeout=120) say(f" restore pressed: {(r.stdout or '').splitlines()[:1]} snapshot {sid}") th.join(460) say(f" the cut:\n{out.get('cut')}") say(f" login after the cut: {wait_login(40)}") time.sleep(30) st = w.stack(APP) code = hold_code() say(f" after the start: state={st.get('state')} hold code={code!r}") say(" controller log:\n" + ctl_log(t0, "crash recovery|could NOT be put back|restore hold|HELD|Notifier enabled")) events = guest(f"cat {SINK_FILE} 2>/dev/null | cut -c1-1400") say(f" the sink's events:\n{events}") mailed = [json.loads(l) for l in events.splitlines() if l.strip().startswith("{") and "put back" in l] say(f" R-1348: operator events with the sentence: {len(mailed)}") return 0 if code == "restore_mixed" and mailed else 1 def phase_teardown(): w.login() say(" blocker: " + guest(f"docker rm -f {BLOCKER} 2>&1 | tail -1")) st = w.stack(APP) say(f" {APP}: deployed={st.get('deployed')} state={st.get('state')} hold={st.get('hold_reason')!r}") if st.get("deployed"): w.remove(APP) say(guest(f"""cp -p {SAVE} {YAML} && cmp {SAVE} {YAML} && echo YAML-RESTORED-IDENTICAL; rm -f {SAVE} pkill -f 'sec1102-sin[k]' || true; rm -f /tmp/sec1102-sink.py /tmp/sec1102-cut.txt cp {SINK_FILE} /tmp/sec1102-sink-keep.jsonl 2>/dev/null; rm -f {SINK_FILE} echo {IMG_FOUND} > /etc/felhom-controller-image systemctl restart felhom-controller-bootstrap.service; sleep 10 docker image rm {IMG_TEST} 2>&1 | tail -1 | cut -c1-80 docker ps --format '{{{{.Names}} }} {{{{.Image}}}} {{{{.Status}}}}' ls /opt/docker/stacks/{APP}/app.yaml 2>&1 | tail -1; docker volume ls --format '{{{{.Name}}}}' | grep '^{APP}_' || echo no-volume python3 - <<'PY' import json s=json.load(open('{VOL}/data/settings.json')) print('holds left:', sorted((s.get('restore_holds') or {{}}).keys())) PY""")) def main(): ph = sys.argv[1] rc = {"boot-before": phase_boot_before, "switch": phase_switch, "boot-after": phase_boot_after, "boot-stage": phase_boot_stage, "r1347": phase_r1347, "r1347b": phase_r1347b, "r1348": phase_r1348, "teardown": phase_teardown}[ph]() or 0 flush(ph) say(f"PHASE {ph} rc={rc}") return rc if __name__ == "__main__": sys.exit(main())