Files
felhom.eu/documentation/audits/prowlarr-2026-10-15/tools/walk_restore.py
T
adminandClaude Opus 5.5 2f1f6c8052
gates / gates (push) Successful in 6m34s
Prowlarr evidence (R-1048) + its apps-page card; the stated app count 62 -> 63; R-1057 filed
- documentation/audits/prowlarr-2026-10-15/: bench (update test proven, C3 failed as it must, soak 7.6 %,
  volume-persistence CLEAN, official-image check), 9202 runs 1-3 (run 1's bump emptied the drill .felhom.yml — a
  test-tool bug, fixed; run 3 re-proved the step and backup -> remove -> restore with the full template), tools.
- website: a Prowlarr card on both apps pages; every stated count 62 -> 63, the media group 19 -> 20; sizing.json +
  sizing-data.json carry Prowlarr (41/44 MB, onboarding), the sizing test and SIZING.md table moved to 63 apps.
- OPEN-ITEMS: R-1057 (a restore of a removed gated app returns without the gate, even from a backup made before the
  setup — measured on 9202); R-1036 notes the count and that wger/plant-it are hidden/abandoned.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 12:58:18 +02:00

137 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""walk_restore.py — 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 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/prowlarr-2026-10-15/box/run3/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']}")
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}")
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()