Files
felhom.eu/documentation/audits/gate-restore-2026-10-15/tools/walk_gate_restore.py
T
admin 15cb62097c
gates / gates (push) Successful in 6m45s
Controller v0.314.0 released under decision 250: floor 0.314.0 raised by CC's token at 13:11 UTC after the three test boxes
- 09 §3 decision 253: a restore puts the setup gate back as it was (R-1057 P2); 07 (what a per-app backup carries); 15 §6
  (Prowlarr links itself; Plex by address).
- Evidence audits/gate-restore-2026-10-15/: 9202 live proofs (R-1057, R-1058), the release (per-customer floors, Tester 1
  rc.1 -> 0.314.0 by itself, box walk, global floor, read-back), Plex fake-code check on 9201, red-proofs.
- Register: R-1023, R-1053, R-1057, R-1058 closed; R-1061 filed (P4); R-1060 narrowed; R-1052 blocked (customer creation
  is session only). STATUS, CONTEXT, the build skill's release steps, Prowlarr's website card.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 15:21:28 +02:00

157 lines
9.7 KiB
Python

#!/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=<evidence dir>; 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()