gates / gates (push) Successful in 6m47s
- 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
194 lines
9.8 KiB
Python
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()
|