#!/usr/bin/env python3 """walk_gate_restore.py (from prowlarr-2026-10-15/tools/walk_restore.py) — R-1057 live proof on controller 0.314.0-rc.1, and NEW-APP-CHECKLIST 2.5 for Prowlarr on scratch guest 9202: seed -> backup -> remove -> restore -> read back, THROUGH THE PRODUCT (R-1048, 2026-10-15, second 9202 run). The box has no per-app „Mentés most" (R-648: the only backup press is whole-box and is never pressed from a drill), so the per-app backup is the one the guarded Update takes in its `backing-up` phase — the same route as grocy's and lubelogger's restore records. Runs ON DOOPLEX with TARGET=9202, SC=<0600 dir holding .ctlpw>, EV=; 9202 must already read the drill mirror, whose prowlarr template is at FROM. Presses: deploy, the drill bump + Update, remove KEEPING the backups, the household's restore button (POST /backup/restore), remove with the backups. """ import json import os import re import subprocess import sys import time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w # noqa: E402 import upgrade_fixtures_box as fx # noqa: E402 EV = os.environ["EV"] APP, SUB = "prowlarr", "prowlarr" FROM = "lscr.io/linuxserver/prowlarr:version-2.5.2.5491" TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623" TOOLS = os.path.dirname(os.path.abspath(__file__)) LOG = open(os.path.join(EV, "restore.txt"), "a", buffering=1) _lib_say = w.say def say(*a): _lib_say(*a) LOG.write(" ".join(str(x) for x in a) + "\n") w.say = say def drill_set(ref, why): """Point the drill template at `ref`, staged by explicit path, then reload the 9202 mirror.""" D = w.DRILL comp, fy = f"{D}/templates/{APP}/docker-compose.yml", f"{D}/templates/{APP}/.felhom.yml" w.sh(["git", "-C", D, "pull", "-q", "--rebase", "origin", "main"], timeout=120) c_ = open(comp).read() other = TO if ref == FROM else FROM if f"image: {ref}" not in c_: open(comp, "w").write(c_.replace(f"image: {other}", f"image: {ref}")) w.sh(["git", "-C", D, "add", f"templates/{APP}/docker-compose.yml", f"templates/{APP}/.felhom.yml"]) w.sh(["git", "-C", D, "commit", "-q", "-m", f"DRILL {APP}: {why} -> {ref} (R-1048, checklist 2.5)"]) r_ = w.sh(["git", "-C", D, "push", "-q", "origin", "main"], timeout=120) say(f"drill commit {w.sh(['git', '-C', D, 'rev-parse', '--short=12', 'HEAD']).stdout.strip()}: -> {ref} (push rc={r_.returncode})") subprocess.run([sys.executable, os.path.join(TOOLS, "repoint9202.py"), "mirror"], check=True, env=dict(os.environ)) def main(): w.login() if w.stack(APP).get("deployed"): sys.exit(say("prowlarr is installed on 9202 — STOP, not a fresh install") or 1) drill_set(FROM, "back to the previous image for a step") w.sync_rescan(expect_app=APP, expect_ref=FROM) code, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": w.DOMAIN, "SUBDOMAIN": SUB}}) say(f"[1] deploy -> {code} {json.dumps(d, ensure_ascii=False)[:120]}") w.DEPLOYED_THIS_RUN.add(APP) for _ in range(60): s = w.stack(APP) if s.get("deployed") and s.get("state") == "running": break time.sleep(3) w.wait_app(SUB, "/ping", want=("200",), tries=40) say("[1] through traefik: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip() + " (gated: 302 to the gate is the healthy answer for a stranger)") say("[1] stack .felhom.yml bytes: " + w.guest(f"wc -c < /opt/docker/stacks/{APP}/.felhom.yml").strip()) say("[1] running: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip()) fxp = fx.FIXTURES[APP] tok = fxp.seed(w, SUB, say) ok1 = bool(tok) and fxp.verify(w, SUB, tok, say) say(f"[2] seed {tok} read back: {ok1}") say("[2] 2.7 config volume size after the seed: " + w.guest("du -sh /var/lib/docker/volumes/prowlarr_prowlarr_config/_data | cut -f1").strip()) say("[2] 4.4 negative control, the same probe on a port nothing serves: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9697/ping; echo $?").strip() + " ; the real probe: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9696/ping; echo $?").strip()) say("[2] 1.8 the app's own start lines: " + w.guest("docker logs prowlarr 2>&1 | grep -m2 -E 'Hosting environment|Application started'").strip().replace("\n", " | ")) drill_set(TO, "the step") w.sync_rescan(expect_app=APP, expect_ref=TO) up = w.press_update(APP) say(f"[3] guarded Update (its backing-up phase is the per-app backup): final={up.get('final_phase')} " f"phases={[p.get('phase') for p in up.get('phases', [])]}") ok2 = bool(tok) and fxp.verify(w, SUB, tok, say) say(f"[3] seed read back after the Update: {ok2}") obs = w.observables(APP) say(f"[3] after: {json.dumps(obs, ensure_ascii=False)[:400]}") s = w.stack(APP) if os.environ.get("VERDICT") == "1": verdict = {"app": APP, "venue": "box 9202 (drill catalog), the product's guarded Update — run 3, the full .felhom.yml", "from": {APP: FROM}, "to": {APP: TO}, "verdict": "proven" if (ok1 and ok2 and up.get("final_phase") == "done" and s.get("state") == "running") else "failed", "seed_read_before": ok1, "seed_read_after": ok2, "healthy_after": s.get("state") == "running", "duration_s": up.get("duration_s"), "final_phase": up.get("final_phase"), "measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "evidence": "felhom.eu/documentation/audits/gate-restore-2026-10-15/9202/restore.txt"} os.makedirs(os.path.join(EV, "..", APP), exist_ok=True) json.dump(verdict, open(os.path.join(EV, "..", APP, "box-verdict-prowlarr.json"), "w"), indent=2) say(f"[3] box verdict {verdict['verdict']}") say("[3] R-1057 the per-app backup's unit app.yaml, its gate lines: " + w.guest( f"f=$(find / -xdev -path '*{APP}*' -name app.yaml -path '*compose*' -newer /etc/felhom-controller-image 2>/dev/null | head -3); " "for x in $f; do echo \"$x: $(grep -A2 '^setup_gate:' $x | tr '\\n' ' ')\"; done").strip()) snaps = w.snapshots(APP) say(f"[4] restore points offered BEFORE the remove: {json.dumps(snaps, ensure_ascii=False)[:400]}") c1, d1 = w.ctl("POST", f"/api/stacks/{APP}/stop") say(f"[4] stop -> {c1}") for _ in range(24): time.sleep(5) if w.stack(APP).get("state") != "running": break code, d = w.ctl("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": False, "remove_backups": False}) say(f"[4] remove KEEPING the backups -> {code} {json.dumps(d, ensure_ascii=False)[:300]}") time.sleep(5) say("[4] left after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr)").strip()) rr = w.restore(APP) say(f"[5] restore: {json.dumps({k: rr.get(k) for k in ('ok', 'snapshot_id', 'http', 'seconds', 'state_after', 'hold_after', 'why')}, ensure_ascii=False)}") w.wait_app(SUB, "/ping", want=("200",), tries=40) ok3 = bool(tok) and fxp.verify(w, SUB, tok, say) say(f"[5] 2.5 the seed reads back after remove + restore: {ok3}") for p, acc in (("/ping", "application/json"), ("/", "text/html"), ("/initialize.json", "application/json")): r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-D", "-", "-H", f"Host: {SUB}.{w.DOMAIN}", "-H", f"Accept: {acc}", f"{w.BASE}{p}"]) st = re.match(r"HTTP/\S+ (\d+)", r.stdout or ""); loc = re.search(r"(?im)^location:\s*(\S+)", r.stdout or "") say(f"[6] R-1057 a STRANGER after the restore: GET {p} -> {st.group(1) if st else '000'} {('loc=' + loc.group(1)[:70]) if loc else ''}") rc, code, _ = w.app_curl(SUB, "/ping", timeout=20) say(f"[6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> {code}") st = w.stack(APP) say(f"[6] gate record after the restore: {json.dumps((st.get('app_config') or {}).get('setup_gate'))}") say("[6] gate file on the box: " + w.guest(f"ls /opt/docker/stacks/traefik/dynamic/setup-gate-{APP}.yml 2>&1").strip()) code, d = w.ctl("POST", f"/apps/{APP}/setup-gate/open") say(f"[7] the household's „Kész, beállítottam” -> {code} {json.dumps(d, ensure_ascii=False)[:120]}") time.sleep(8) r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-w", "%{http_code}", "-H", f"Host: {SUB}.{w.DOMAIN}", f"{w.BASE}/ping"]) say(f"[7] a stranger after „Kész”: GET /ping -> {r.stdout} (the app itself answers now)") say("[5] through traefik after restore: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip()) say("[5] app.yaml env keys after restore: " + w.guest(f"sed -n '/^env:/,/^[a-z]/p' /opt/docker/stacks/{APP}/app.yaml | grep -oE '^ +[A-Z_]+' | paste -sd' '").strip()) say("[5] image after restore: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip()) # teardown through the product, backups too (this is a scratch install) w.remove(APP) say("[9] after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr)").strip()) json.dump({"seed": tok, "seed_before": ok1, "after_update": ok2, "after_restore": ok3, "update_final": up.get("final_phase"), "restore": {k: rr.get(k) for k in ("ok", "snapshot_id", "http", "seconds", "state_after")}, "measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, open(os.path.join(EV, "restore-results.json"), "w"), indent=2, ensure_ascii=False) main()