Files
felhom.eu/documentation/audits/prowlarr-2026-10-15/tools/walk_prowlarr.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

208 lines
12 KiB
Python

#!/usr/bin/env python3
"""walk_prowlarr.py — Prowlarr on scratch guest 9202 THROUGH THE PRODUCT, from the drill catalog (R-1048, 2026-10-15).
Runs ON DOOPLEX with TARGET=9202 and SC=<0600 dir holding .ctlpw>. 9202 must already be pointed at the drill mirror,
whose prowlarr template is the live candidate at the PREVIOUS image (version-2.5.2.5491). Presses only what a person
presses (box_walk.py): deploy, the setup gate as household and stranger, Prowlarr's own first-admin and sign-in routes
through traefik over https, „Kész", „Mentés most", the drill bump + the guarded Update, remove.
Closes NEW-APP-CHECKLIST 3.1 3.3 3.5 3.6 3.9 3.10 2.5 2.6 4.3 6.2(box) 6.3 9.2. Writes the evidence to EV and the box
verdict to EV/prowlarr/box-verdict-prowlarr.json (the ladder writer's input). The household password it creates is kept
only in SC (0600) for the life of the run; the app's API key is never printed.
"""
import json
import os
import re
import secrets
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"
os.makedirs(os.path.join(EV, APP), exist_ok=True)
LOG = open(os.path.join(EV, "walk.txt"), "a", buffering=1)
RES = {}
_lib_say = w.say
def say(*a):
_lib_say(*a)
LOG.write(" ".join(str(x) for x in a) + "\n")
w.say = say # the library's own lines land in the same record (the original is kept above — no recursion)
def stranger(path, method="GET", data=None, accept="application/json", extra=()):
"""A visitor with NO dashboard session and NO gate cookie, on the app's public name over https."""
args = ["curl", "-sSk", "--max-time", "20", "-o", "/tmp/pw-body.txt", "-D", "-", "-H", f"Host: {SUB}.{w.DOMAIN}",
"-H", f"Accept: {accept}"] + list(extra)
if method != "GET":
args += ["-X", method]
if data is not None:
args += ["--data-binary", "@-"]
r = w.sh(args + [f"{w.BASE}{path}"], inp=data)
head = r.stdout or ""
st = re.match(r"HTTP/\S+ (\d+)", head)
loc = re.search(r"(?im)^location:\s*(\S+)", head)
body = w.sh(["head", "-c", "200", "/tmp/pw-body.txt"]).stdout.replace("\n", " ")
return (st.group(1) if st else "000"), (loc.group(1) if loc else ""), body, head
def household(path, method="GET", body=None, headers=()):
ext = []
for h in headers:
ext += ["-H", h]
if body is not None:
ext += ["-H", "Content-Type: application/json"]
rc, code, out = w.app_curl(SUB, path, *ext, method=method, data=json.dumps(body) if body is not None else None)
return code, out
def main():
w.login()
st = w.stack(APP)
if st.get("deployed"):
sys.exit(say("prowlarr is installed on 9202 — STOP, not a fresh install") or 1)
# ── 3.5 + 4.3: install, and a stranger polls the public name once a second through the install window ──
t0 = time.time()
code, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": w.DOMAIN, "SUBDOMAIN": SUB}})
say(f"[1] POST /api/stacks/{APP}/deploy -> {code} {json.dumps(d, ensure_ascii=False)[:160]}")
w.DEPLOYED_THIS_RUN.add(APP)
seen = {}
for i in range(90):
for p in ("/", "/initialize.json", "/api/v1/system/status"):
c, loc, body, _ = stranger(p, accept="text/html" if p == "/" else "application/json")
key = f"{p} -> {c} {('loc=' + loc[:60]) if loc else ''} {'API-KEY-IN-BODY' if 'apiKey' in body else ''}".strip()
seen[key] = seen.get(key, 0) + 1
s = w.stack(APP)
if s.get("deployed") and s.get("state") == "running" and i > 20:
break
time.sleep(1)
say(f"[1] install window, a stranger on the public name ({round(time.time() - t0)} s, distinct answers):")
for k, n in sorted(seen.items()):
say(f" {n:3d}x {k}")
RES["install_window"] = seen
s = w.stack(APP)
say(f"[1] state={s.get('state')} health={s.get('health')} restarts: "
+ w.guest("docker inspect prowlarr -f '{{.RestartCount}} started={{.State.StartedAt}} health={{.State.Health.Status}}'").strip())
say("[1] the first-start seed line: " + w.guest("docker logs prowlarr 2>&1 | grep -m1 felhom").strip())
say("[1] config.xml (key redacted): " + w.guest("docker exec prowlarr cat /config/config.xml | sed -E 's#<ApiKey>[^<]+#<ApiKey><redacted>#' | tr '\\n' ' '").strip())
# ── 3.1: the household reaches the first-run screen through the gate; its key comes from the app's own page ──
code, out = household("/initialize.json")
m = re.search(r'"?apiKey"?\s*:\s*"([0-9a-fA-F]{20,})"', out)
say(f"[2] household GET /initialize.json through the gate -> {code}, carries the API key: {bool(m)}")
key = m.group(1) if m else ""
code, out = household("/api/v1/config/host", headers=(f"X-Api-Key: {key}",))
hc = json.loads(out) if code == "200" else {}
say(f"[2] GET /api/v1/config/host -> {code}: authenticationMethod={hc.get('authenticationMethod')} "
f"authenticationRequired={hc.get('authenticationRequired')} analyticsEnabled={hc.get('analyticsEnabled')}")
user, pw = "felhom-test", "Pw-" + secrets.token_urlsafe(14)
open(os.path.join(w.SC, "prowlarr-household.json"), "w").write(json.dumps({"user": user, "password": pw}))
os.chmod(os.path.join(w.SC, "prowlarr-household.json"), 0o600)
hc.update({"authenticationMethod": "forms", "username": user, "password": pw, "passwordConfirmation": pw})
code, out = household("/api/v1/config/host", method="PUT", body=hc, headers=(f"X-Api-Key: {key}",))
say(f"[2] the household makes its first account (PUT /api/v1/config/host, forms) -> {code}")
RES["first_admin"] = code
time.sleep(8)
w.wait_app(SUB, "/ping", want=("200",), tries=24)
# ── 3.9: sign in the way the browser does, through traefik over https (still behind the gate) ──
for label, p in (("right", pw), ("wrong", "nope-" + secrets.token_hex(4))):
rc, code, out = w.app_curl(SUB, "/login?returnUrl=%2F", "-H", "Content-Type: application/x-www-form-urlencoded",
"-D", "-", method="POST", data=f"username={user}&password={p}&rememberMe=on")
loc = re.search(r"(?im)^location:\s*(\S+)", out)
ck = re.search(r"(?im)^set-cookie:\s*([A-Za-z]+Auth)=", out)
say(f"[3] sign in ({label} password) POST /login -> {code} loc={loc.group(1) if loc else '-'} auth-cookie={ck.group(1) if ck else 'none'}")
RES[f"signin_{label}"] = [code, loc.group(1) if loc else "", bool(ck)]
# ── 6.1 C1 + 2.5: seed through the app's own API, read back, back it up through the product ──
fxp = fx.FIXTURES[APP]
tok = fxp.seed(w, SUB, say)
ok1 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[4] seed {tok} read back before the update: {ok1}")
bk = w.backup_now(APP)
say(f"[4] „Mentés most” -> {json.dumps(bk, ensure_ascii=False)[:200]}")
# ── 3.3: „Kész" opens the gate; then a stranger meets the APP's own sign-in, never the app ──
code, d = w.ctl("POST", f"/apps/{APP}/setup-gate/open")
say(f"[5] the household's „Kész, beállítottam” -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
time.sleep(5)
for p, acc in (("/", "text/html"), ("/initialize.json", "application/json"), ("/api/v1/system/status", "application/json"),
("/api/v1/indexer", "application/json")):
c, loc, body, _ = stranger(p, accept=acc)
say(f"[5] stranger after the gate opened: GET {p} -> {c} {('loc=' + loc[:70]) if loc else ''} apiKey-in-body={'apiKey' in body}")
RES[f"stranger_open_{p}"] = [c, loc, "apiKey" in body]
# ── 3.6 + 3.10: six wrong passwords from a stranger; then the household; and what address the app logged ──
for i in range(6):
c, loc, _b, _ = stranger("/login?returnUrl=%2F", method="POST", data=f"username={user}&password=wrong{i}",
accept="text/html", extra=("-H", "Content-Type: application/x-www-form-urlencoded"))
say(f"[6] stranger wrong password #{i + 1} -> {c} loc={loc[:60]}")
c, loc, _b, head = stranger("/login?returnUrl=%2F", method="POST", data=f"username={user}&password={pw}",
accept="text/html", extra=("-H", "Content-Type: application/x-www-form-urlencoded"))
say(f"[6] the household's right password right after (same address) -> {c} loc={loc[:60]} "
f"auth-cookie={'yes' if re.search(r'(?im)^set-cookie:.*Auth=', head) else 'no'}")
say("[6] what the app logged for the failures (address): "
+ w.guest("docker exec prowlarr sh -c \"grep -h -i 'auth-failure\\|Auth-' /config/logs/prowlarr*.txt | tail -3\"").strip().replace("\n", " | ")[:600])
# ── 6.2/6.3 box: the drill bump and the product's guarded Update; the seed read back after ──
# the drill bump, staged by explicit path (box_walk.drill_bump runs `git add -A`, which this task forbids)
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()
if f"image: {FROM}" not in c_:
sys.exit(say(f"[7] FROM {FROM} not in the drill compose — STOP") or 1)
open(comp, "w").write(c_.replace(f"image: {FROM}", f"image: {TO}"))
# FIXED after run 1: this line used to be `open(fy, "w").write(re.sub(..., open(fy).read(), ...))`, which truncates the
# file BEFORE it is read — run 1's bump commit (drill f2b0cc1) emptied the drill's .felhom.yml, so run 1's Update ran
# without the app's metadata. Run 3 (walk_restore.py, box/run3/) re-proves the step with the full file.
fy_text = open(fy).read()
open(fy, "w").write(re.sub(r'^catalog_since:.*$', 'catalog_since: "' + time.strftime("%Y-%m-%d") + '"', fy_text, count=1, flags=re.M))
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}: {FROM} -> {TO} (R-1048 box proof)"])
r_ = w.sh(["git", "-C", D, "push", "-q", "origin", "main"], timeout=120)
say(f"[7] drill commit {w.sh(['git', '-C', D, 'rev-parse', '--short=12', 'HEAD']).stdout.strip()}: {FROM} -> {TO} (push rc={r_.returncode})")
# the mirror on 9202 is what the box reads: reload it from the drill repo
import subprocess
subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "repoint9202.py"), "mirror"], check=True,
env=dict(os.environ))
w.sync_rescan(expect_app=APP, expect_ref=TO)
say(f"[7] badges: {json.dumps(w.badges(APP), ensure_ascii=False)[:300]}")
up = w.press_update(APP)
say(f"[7] guarded Update: {json.dumps(up, ensure_ascii=False)[:400]}")
ok2 = bool(tok) and fxp.verify(w, SUB, tok, say)
obs = w.observables(APP)
say(f"[7] after: {json.dumps(obs, ensure_ascii=False)[:400]}")
s = w.stack(APP)
healthy = s.get("state") == "running"
verdict = {"app": APP, "venue": "box 9202 (drill catalog, controller 0.304.0), the product's guarded Update",
"from": {APP: FROM}, "to": {APP: TO},
"verdict": "proven" if (ok1 and ok2 and up.get("final_phase") == "done" and healthy) else "failed",
"seed_read_before": ok1, "seed_read_after": ok2, "healthy_after": healthy,
"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/walk.txt"}
json.dump(verdict, open(os.path.join(EV, APP, "box-verdict-prowlarr.json"), "w"), indent=2)
say(f"[8] box verdict {verdict['verdict']}")
# ── 2.6: remove through the product; nothing left ──
if os.environ.get("KEEP") != "1":
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) stackdir=$(ls -d /opt/docker/stacks/prowlarr 2>/dev/null | wc -l)").strip())
json.dump(RES, open(os.path.join(EV, "walk-results.json"), "w"), indent=2, ensure_ascii=False)
main()