gates / gates (push) Successful in 6m34s
- 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
208 lines
12 KiB
Python
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()
|