Files
felhom.eu/documentation/audits/prowlarr-2026-10-15/tools/walk_9201_link.py
T
adminandClaude Opus 5.5 02be5006bb
gates / gates (push) Successful in 6m47s
Prowlarr linked on 9201 (live catalog): R-1048 closed, R-1058 filed; the session report
- audits/prowlarr-2026-10-15/box9201: Sonarr then Prowlarr — no link until „Bekötés most"; Radarr after Prowlarr —
  linked by itself ~60 s later; Prowlarr's connection test 200 for both, its sync command completed; all removed
  keeping data. The first run's key-quoting defect in the tool is recorded and fixed.
- R-1058 (Apps & catalog, P3): the wiring does not start when Prowlarr itself comes up.
- R-1048 closed (CLOSED-ITEMS). STATUS, CONTEXT. REPORT-release-rule-prowlarr-2026-10-15.md.

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

194 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""walk_9201_link.py — the read-back on demo-hp 9201 from the LIVE catalog after the publish (R-1048, brief Part D3).
Runs ON DOOPLEX with TARGET=9201, SC=<0600 dir holding .ctlpw>, EV=<evidence dir>. Presses only what a person presses:
POST /api/sync, the deploy wizard's POST /api/stacks/<n>/deploy, the dashboard's „Bekötés most" (POST
/api/storage/media/wire), stop + remove KEEPING data. Reads Prowlarr's own API inside its container with the key from
its own config.xml (never printed), exactly as the box's wiring does.
The two orders the controller's code implies (mediawire runs when a media_library app comes up; Prowlarr has no marker):
B Sonarr first, Prowlarr after -> expected NO link by itself; „Bekötés most" makes it
A Radarr after Prowlarr -> expected the link by itself, ~60 s after Radarr comes up
Then: Prowlarr's own connection test for each link, and its sync command to completion.
"""
import json
import os
import re
import shlex
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
EV = os.environ["EV"]
os.makedirs(EV, exist_ok=True)
LOG = open(os.path.join(EV, "link.txt"), "a", buffering=1)
TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"
HDD = "/mnt/felhom-drives/hdd_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
def papi(path, body=None, method=None):
# FIXED 2026-10-15 after the first run: json.dumps() quoted this with DOUBLE quotes, so the guest shell expanded
# $(sed …) and $K before docker exec — empty key, every call 401 (link.txt [B3]/[C2]). shlex.quote keeps it literal.
"""Prowlarr's own API from inside its container (the key read from its config.xml at use, never printed)."""
m = method or ("POST" if body is not None else "GET")
data = json.dumps(body) if body is not None else ""
script = ("K=$(sed -n 's:.*<ApiKey>\\(.*\\)</ApiKey>.*:\\1:p' /config/config.xml); "
f"curl -s -o /tmp/pb.json -w '%{{http_code}}' -X {m} -H \"X-Api-Key: $K\" -H 'Content-Type: application/json' "
f"--data-binary @- http://127.0.0.1:9696/api/v1{path}; echo; cat /tmp/pb.json")
out = w.guest(f"docker exec -i prowlarr sh -c {shlex.quote(script)} <<'EOF'\n{data}\nEOF")
code, _, body_ = out.partition("\n")
try:
return code.strip(), json.loads(body_) if body_.strip() else None
except Exception:
return code.strip(), body_[:300]
def apps():
c, d = papi("/applications")
return [(a.get("name"), a.get("syncLevel"), next((f.get("value") for f in a.get("fields", []) if f.get("name") == "baseUrl"), None))
for a in (d or [])] if c == "200" else c
def wiring_lines(since):
return w.guest(f"docker logs --since {since} felhom-controller 2>&1 | grep -E '\\[media\\] wiring|\\[mediawire\\]' | cut -c1-400").strip()
def install(name, values):
if w.stack(name).get("deployed"):
sys.exit(say(f"{name} is already installed on 9201 — STOP, a household's app is never reused") or 1)
code, d = w.ctl("POST", f"/api/stacks/{name}/deploy", {"values": values})
say(f" deploy {name} {json.dumps(values)} -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
w.DEPLOYED_THIS_RUN.add(name)
for _ in range(90):
time.sleep(5)
st = w.stack(name)
if st.get("deployed") and (st.get("app_config") or {}).get("pinned_images") and st.get("state") == "running":
say(f" {name}: deployed, running, pinned={(st.get('app_config') or {}).get('pinned_images')}")
return True
sys.exit(say(f" {name} never became running — STOP") or 1)
def wait_healthy(c, tries=60):
for _ in range(tries):
if w.guest(f"docker inspect {c} -f '{{{{.State.Health.Status}}}}'").strip() == "healthy":
return True
time.sleep(5)
return False
def main():
w.login()
if os.environ.get("CONTINUE") == "1":
return part2()
t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
for a in ("prowlarr", "sonarr", "radarr"):
if w.stack(a).get("deployed"):
sys.exit(say(f"{a} is installed on 9201 — STOP") or 1)
say("[0] controller: " + w.guest("cat /etc/felhom-controller-image").strip())
w.sync_rescan(expect_app="prowlarr", expect_ref=TO)
say("[0] live catalog on the box: " + w.guest("docker exec felhom-controller sh -c 'git -C /opt/docker/felhom-controller/data/catalog-cache log -1 --format=\"%h %s\"; git -C /opt/docker/felhom-controller/data/catalog-cache remote get-url origin'").strip().replace("\n", " | "))
say("[0] prowlarr template on the box: " + w.guest("wc -c /opt/docker/stacks/prowlarr/.felhom.yml; grep -E 'image:' /opt/docker/stacks/prowlarr/docker-compose.yml; grep -E '^setup_gate' /opt/docker/stacks/prowlarr/.felhom.yml").strip().replace("\n", " | "))
# ── order B: Sonarr first ──
install("sonarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "sonarr", "HDD_PATH": HDD})
wait_healthy("sonarr")
time.sleep(75) # the wiring runs 60 s after the last media app came up
say("[B1] wiring after Sonarr came up:\n" + wiring_lines(t0))
tP = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
install("prowlarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "prowlarr"})
wait_healthy("prowlarr")
say("[B2] prowlarr first-start line: " + w.guest("docker logs prowlarr 2>&1 | grep -m1 felhom").strip())
time.sleep(150)
b_apps = apps()
b_wire = wiring_lines(tP)
say(f"[B3] 150 s after Prowlarr came up (Sonarr was there first): Prowlarr's Apps = {b_apps}")
say(f"[B3] wiring runs since Prowlarr's install: {b_wire or '(none)'}")
RES["order_B_apps_without_press"] = b_apps
RES["order_B_wiring_runs"] = b_wire
# ── the household's „Bekötés most" ──
code, d = w.ctl("POST", "/api/storage/media/wire", {})
pr = [r for r in ((d or {}).get("data") or []) if isinstance(r, dict) and r.get("app") == "prowlarr"]
say(f"[C1] „Bekötés most” (POST /api/storage/media/wire) -> {code}; prowlarr result: {json.dumps(pr, ensure_ascii=False)}")
c_apps = apps()
say(f"[C2] Prowlarr's Apps after the press: {c_apps}")
RES["after_press_apps"] = c_apps
# ── order A: Radarr after Prowlarr ──
tR = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
install("radarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "radarr", "HDD_PATH": HDD})
wait_healthy("radarr")
part2(tR)
def part2(tR=None):
"""From order A's check on (CONTINUE=1 re-enters here after the first run's key bug; the installs stand)."""
if tR is None:
tR = w.guest("docker inspect radarr -f '{{.State.StartedAt}}'").strip()[:19] + "Z"
say(f"[CONTINUE] re-entered after the key fix; Radarr started {tR}; Sonarr, Prowlarr, Radarr installed by run 1 of this script")
say(f"[CONTINUE] Prowlarr's Apps read now with the right key: {apps()}")
a_apps = None
for i in range(30):
time.sleep(10)
a_apps = apps()
if isinstance(a_apps, list) and any(n == "Radarr" for n, _, _ in a_apps):
say(f"[A1] Radarr appeared in Prowlarr's Apps by itself, ~{(i + 1) * 10} s after Radarr ran")
break
say(f"[A2] Prowlarr's Apps: {a_apps}")
say("[A2] wiring runs since Radarr's install:\n" + wiring_lines(tR))
RES["order_A_apps"] = a_apps
# ── the links work: Prowlarr's own test per app, then its sync command to completion ──
c, full = papi("/applications")
for a in (full or []):
tc, td = papi("/applications/test", a)
say(f"[D1] Prowlarr's connection test for {a.get('name')} ({a.get('implementation')}, syncLevel {a.get('syncLevel')}) -> {tc} {str(td)[:160] if td else ''}")
RES[f"test_{a.get('name')}"] = tc
c, cmd = papi("/command", {"name": "ApplicationIndexerSync"})
cid = (cmd or {}).get("id") if isinstance(cmd, dict) else None
say(f"[D2] sync command ApplicationIndexerSync -> {c} id={cid}")
st = None
for _ in range(30):
time.sleep(2)
c, cd = papi(f"/command/{cid}")
st = (cd or {}).get("status") if isinstance(cd, dict) else cd
if st in ("completed", "failed", "aborted"):
break
say(f"[D2] sync command status: {st}")
RES["sync_status"] = st
say("[D3] Prowlarr's own log of the sync: " + w.guest(
"docker exec prowlarr sh -c \"grep -h -iE 'ApplicationIndexerSync|Syncing|sonarr:8989|radarr:7878' /config/logs/prowlarr.txt | tail -6\"").strip().replace("\n", " | ")[:900])
say("[D4] Sonarr's and Radarr's indexer lists (Prowlarr has no indexer yet, so both empty is the expected answer): "
+ w.guest("for a in sonarr:8989:v3 radarr:7878:v3; do n=${a%%:*}; r=${a#*:}; p=${r%%:*}; v=${r#*:}; "
"docker exec $n sh -c \"K=\\$(sed -n 's:.*<ApiKey>\\(.*\\)</ApiKey>.*:\\1:p' /config/config.xml); "
"curl -s -H \\\"X-Api-Key: \\$K\\\" http://127.0.0.1:$p/api/$v/indexer\" | head -c 200; echo \" <- $n\"; done").strip().replace("\n", " | "))
# ── remove all three, KEEPING data (the brief: without „delete data") ──
for a in ("radarr", "prowlarr", "sonarr"):
c1, _ = w.ctl("POST", f"/api/stacks/{a}/stop")
for _ in range(24):
time.sleep(5)
if w.stack(a).get("state") != "running":
break
code, d = w.ctl("POST", f"/api/stacks/{a}/remove", {"remove_hdd_data": False, "remove_backups": False})
say(f"[X] {a}: stop {c1}, remove keeping data -> {code} {json.dumps(d, ensure_ascii=False)[:260]}")
time.sleep(5)
say("[X] left: " + w.guest("echo containers=$(docker ps -aq -f name='^(prowlarr|sonarr|radarr)$' | wc -l) "
"volumes=$(docker volume ls -q | grep -cE '^(prowlarr|sonarr|radarr)_')").strip())
json.dump(RES, open(os.path.join(EV, "link-results.json"), "w"), indent=2, ensure_ascii=False)
main()