15cb62097c
gates / gates (push) Successful in 6m45s
- 09 §3 decision 253: a restore puts the setup gate back as it was (R-1057 P2); 07 (what a per-app backup carries); 15 §6 (Prowlarr links itself; Plex by address). - Evidence audits/gate-restore-2026-10-15/: 9202 live proofs (R-1057, R-1058), the release (per-customer floors, Tester 1 rc.1 -> 0.314.0 by itself, box walk, global floor, read-back), Plex fake-code check on 9201, red-proofs. - Register: R-1023, R-1053, R-1057, R-1058 closed; R-1061 filed (P4); R-1060 narrowed; R-1052 blocked (customer creation is session only). STATUS, CONTEXT, the build skill's release steps, Prowlarr's website card. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
142 lines
6.6 KiB
Python
142 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""walk_link.py (from prowlarr-2026-10-15/tools/walk_9201_link.py) — R-1058 live proof on controller 0.314.0-rc.1:
|
|
Sonarr first, then Prowlarr: the link must appear with NO button; a second run changes nothing.
|
|
|
|
(original docstring:) 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 = os.environ.get("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()
|
|
for a in ("prowlarr", "sonarr"):
|
|
if w.stack(a).get("deployed"):
|
|
sys.exit(say(f"{a} is installed — STOP") or 1)
|
|
say("[0] controller: " + w.guest("cat /etc/felhom-controller-image").strip())
|
|
t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
install("sonarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "sonarr", "HDD_PATH": HDD})
|
|
wait_healthy("sonarr")
|
|
time.sleep(75)
|
|
say("[1] 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")
|
|
linked = None
|
|
for i in range(18):
|
|
time.sleep(10)
|
|
a = apps()
|
|
if isinstance(a, list) and any(n == "Sonarr" for n, _, _ in a):
|
|
linked = (i + 1) * 10
|
|
say(f"[2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~{linked} s after Prowlarr ran: {a}")
|
|
break
|
|
if linked is None:
|
|
say(f"[2] R-1058 FAILED: no link 180 s after Prowlarr ran; Apps = {apps()}")
|
|
say("[2] wiring runs since Prowlarr's install:\n" + wiring_lines(tP))
|
|
RES["linked_after_s"] = linked
|
|
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"[3] a second run („Bekötés most”) -> {code}; prowlarr result: {json.dumps(pr, ensure_ascii=False)} (expected: unchanged, nothing written)")
|
|
RES["second_run"] = pr
|
|
c, full = papi("/applications")
|
|
for a in (full or []):
|
|
tc, td = papi("/applications/test", a)
|
|
say(f"[4] Prowlarr's connection test for {a.get('name')} -> {tc}")
|
|
for a in ("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": True})
|
|
say(f"[X] {a}: remove (keep drive data, drop backups) -> {code} {json.dumps(d, ensure_ascii=False)[:200]}")
|
|
time.sleep(5)
|
|
say("[X] left: " + w.guest("echo containers=$(docker ps -aq -f name='^(prowlarr|sonarr)$' | wc -l) volumes=$(docker volume ls -q | grep -cE '^(prowlarr|sonarr)_')").strip())
|
|
json.dump(RES, open(os.path.join(EV, "link-results.json"), "w"), indent=2, ensure_ascii=False)
|
|
|
|
|
|
main()
|