Files
felhom.eu/documentation/audits/gate-restore-2026-10-15/tools/walk_link.py
T
admin 15cb62097c
gates / gates (push) Successful in 6m45s
Controller v0.314.0 released under decision 250: floor 0.314.0 raised by CC's token at 13:11 UTC after the three test boxes
- 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
2026-10-15 15:21:28 +02:00

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()