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
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
This commit is contained in:
2026-10-15 15:21:28 +02:00
parent bd478afe14
commit 15cb62097c
40 changed files with 945 additions and 22 deletions
@@ -0,0 +1,5 @@
before: gitea.dooplex.hu/admin/felhom-controller:0.304.0
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1 Up 25 seconds (healthy)
2026/10/15 12:35:53 main.go:354: [INFO] felhom-controller 0.314.0-rc.1 starting (customer: demo-hp, domain: enkisfelhom.hu)
2026/10/15 12:35:53 undo.go:676: [INFO] [stacks] applied-meta backfill (R-646): recorded 0 []; skipped 0 [] — not current with the catalog, their pinned version's .felhom.yml is no longer on the box
@@ -0,0 +1,6 @@
drill repo main: ebf3668
mirror main ebf3668 DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
cache HEAD ebf3668 DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
file:///opt/docker/felhom-controller/drill-catalog.git
@@ -0,0 +1,7 @@
2026/10/15 12:38:45 setup_gate.go:295: [INFO] [stacks] prowlarr: setup gate CLOSED before the first start — only the household reaches [prowlarr.enkisfelhom.hu] until the first setup is done
2026/10/15 12:40:07 recovery_unit.go:289: [INFO] [backup] Recovery unit captured for prowlarr → /mnt/sys_drive/felhom-data/backups/primary/prowlarr (images=1, secrets-referenced=0, data_keys=0, portable-carried=0/0, withheld=0)
2026/10/15 12:41:36 restore_unit.go:381: [INFO] [backup] Restore prowlarr: the data belongs to [lscr.io/linuxserver/prowlarr:version-2.5.2.5491] (written 2026-10-15T12:40:06Z); the app ran [lscr.io/linuxserver/prowlarr:version-2.6.5.5623] — it comes back at
2026/10/15 12:41:36 restore_unit.go:393: [INFO] [backup] Restoring prowlarr from recovery unit /mnt/sys_drive/felhom-data/backups/primary/prowlarr: images=1, secrets recovered=0/0, data_keys=0
2026/10/15 12:41:37 setup_gate.go:295: [INFO] [stacks] prowlarr: setup gate CLOSED before the first start — only the household reaches [prowlarr.enkisfelhom.hu] until the first setup is done
2026/10/15 12:41:37 restore_gate.go:86: [INFO] [stacks] prowlarr: restored with its setup gate CLOSED — it was closed when the backup was taken (R-1057)
2026/10/15 12:41:54 restore_unit.go:487: [INFO] [backup] Restore-from-unit completed: prowlarr — 1 volume(s) of 1 listed, 0 database(s) of 0 listed
@@ -0,0 +1,7 @@
27: sync_interval: 15m
-rw------- 1 root root 1944 Oct 12 20:52 /var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml.pre-prowlarr1015
mirror main c42d92b DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
cache HEAD 73b45a6 Prowlarr into the live catalog, with its complete onboarding record (R-1048)
https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
repo_url: https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
@@ -0,0 +1,2 @@
gitea.dooplex.hu/admin/felhom-controller:0.314.0 Up 25 seconds (healthy)
0
@@ -0,0 +1,17 @@
14:49:28 [0] controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
14:49:28 deploy sonarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "sonarr", "HDD_PATH": "/mnt/felhom-drives/scratch_hdd"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:49:53 sonarr: deployed, running, pinned={'sonarr': 'lscr.io/linuxserver/sonarr:4.0.20'}
14:51:15 [1] wiring after Sonarr came up:
2026/10/15 12:50:37 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: sonarr)
2026/10/15 12:50:38 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 984ms: folders=done(0) sonarr=done(3)
14:51:15 deploy prowlarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "prowlarr"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:51:45 prowlarr: deployed, running, pinned={'prowlarr': 'lscr.io/linuxserver/prowlarr:version-2.6.5.5623'}
14:52:42 [2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~40 s after Prowlarr ran: [('Sonarr', 'fullSync', 'http://sonarr:8989')]
14:52:46 [2] wiring runs since Prowlarr's install:
2026/10/15 12:52:27 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: prowlarr)
2026/10/15 12:52:28 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 1.817s: folders=done(0) sonarr=unchanged(0) prowlarr=done(1)
14:52:46 [3] a second run („Bekötés most”) -> 200; prowlarr result: [{"app": "prowlarr", "state": "unchanged"}] (expected: unchanged, nothing written)
14:52:52 [4] Prowlarr's connection test for Sonarr -> 200
14:53:28 [X] prowlarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "St
14:54:03 [X] sonarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "sonarr", "volumes_removed": ["sonarr_sonarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": ["/mnt/felhom-drives/scratch_hdd/userdata/m
14:54:12 [X] left: containers=0 volumes=0
@@ -0,0 +1,9 @@
{
"linked_after_s": 40,
"second_run": [
{
"app": "prowlarr",
"state": "unchanged"
}
]
}
@@ -0,0 +1,17 @@
[0] controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
deploy sonarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "sonarr", "HDD_PATH": "/mnt/felhom-drives/scratch_hdd"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
sonarr: deployed, running, pinned={'sonarr': 'lscr.io/linuxserver/sonarr:4.0.20'}
[1] wiring after Sonarr came up:
2026/10/15 12:50:37 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: sonarr)
2026/10/15 12:50:38 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 984ms: folders=done(0) sonarr=done(3)
deploy prowlarr {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "prowlarr"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
prowlarr: deployed, running, pinned={'prowlarr': 'lscr.io/linuxserver/prowlarr:version-2.6.5.5623'}
[2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~40 s after Prowlarr ran: [('Sonarr', 'fullSync', 'http://sonarr:8989')]
[2] wiring runs since Prowlarr's install:
2026/10/15 12:52:27 media_events.go:69: [INFO] [media] wiring the drives into the media apps (a media app came up: prowlarr)
2026/10/15 12:52:28 mediawire.go:154: [INFO] [mediawire] 1 drive(s) wired in 1.817s: folders=done(0) sonarr=unchanged(0) prowlarr=done(1)
[3] a second run („Bekötés most”) -> 200; prowlarr result: [{"app": "prowlarr", "state": "unchanged"}] (expected: unchanged, nothing written)
[4] Prowlarr's connection test for Sonarr -> 200
[X] prowlarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "St
[X] sonarr: remove (keep drive data, drop backups) -> 200 {"ok": true, "data": {"removed": "sonarr", "volumes_removed": ["sonarr_sonarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": ["/mnt/felhom-drives/scratch_hdd/userdata/m
[X] left: containers=0 volumes=0
@@ -0,0 +1,60 @@
14:37:21 drill commit fd4b9f1541db: -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (push rc=0)
mirror main fd4b9f1 DRILL prowlarr: back to the previous image for a step -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (R-1048, checklist 2.5)
drill repo main: fd4b9f1
14:38:45 [sync] the badge NEVER caught up to lscr.io/linuxserver/prowlarr:version-2.5.2.5491 in 78.5s — catalog_images = None
14:38:45 [1] deploy -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
14:39:03 gate: prowlarr is gated — passed as the household (cookie set)
14:39:07 [1] through traefik: 401 (gated: 302 to the gate is the healthy answer for a stranger)
14:39:10 [1] stack .felhom.yml bytes: 3948
14:39:13 [1] running: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
14:39:22 prowlarr: POST /tag felhom20fd668a http=201
14:39:26 prowlarr: POST /appprofile felhom-profile-c7f82e http=201
14:39:39 prowlarr: tag kept, app profile present=True
14:39:39 [2] seed {'tag': 'felhom20fd668a', 'profile': 'felhom-profile-c7f82e'} read back: True
14:39:42 [2] 2.7 config volume size after the seed: 5.4M
14:39:48 [2] 4.4 negative control, the same probe on a port nothing serves: rc=7 ; the real probe: rc=0
14:39:51 [2] 1.8 the app's own start lines: [Info] Microsoft.Hosting.Lifetime: Application started. Press Ctrl+C to shut down. | [Info] Microsoft.Hosting.Lifetime: Hosting environment: Production
14:39:52 drill commit c42d92bc425d: -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (push rc=0)
mirror main c42d92b DRILL prowlarr: the step -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (R-1048, checklist 2.5)
drill repo main: c42d92b
14:40:02 [6] Update -> 202 {'ok': True, 'data': {'accepted': True, 'completed': False}, 'message': 'Frissítés elindult – az állapot a kártyán követhető'}
14:40:02 + 0.0s phase=backing-up label=Biztonsági mentés készül a frissítés előtt… err=None hold=None
14:40:07 + 5.1s phase=safety-dump label=Adatbázis pillanatkép… err=None hold=None
14:40:09 + 6.2s phase=pulling label=Új verzió letöltése… err=None hold=None
14:40:17 + 14.4s phase=copying label=Az adatok másolása a frissítés előtt… err=None hold=None
14:40:22 + 19.5s phase=verifying label=Működés ellenőrzése… err=None hold=None
14:40:32 + 29.7s phase=done label=Frissítve err=None hold=None
14:40:32 [3] guarded Update (its backing-up phase is the per-app backup): final=done phases=['backing-up', 'safety-dump', 'pulling', 'copying', 'verifying', 'done']
14:40:46 prowlarr: tag kept, app profile present=True
14:40:46 [3] seed read back after the Update: True
14:40:49 [3] after: {"pinned_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "installed_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "catalog_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "live_compose_image_lines": ["image: lscr.io/linuxserver/prowlarr:version-2.6.5.5623"], "docker_inspect": ["prowlarr lscr.io/linuxserver/prowlarr:ve
14:40:52 [3] R-1057 the per-app backup's unit app.yaml, its gate lines:
14:40:52 [4] restore points offered BEFORE the remove: [{"time": "2026-10-15T12:40:06Z", "short_id": "helyi", "tier": 1, "drive_label": "Belső SSD (rendszer)"}]
14:40:56 [4] stop -> 200
14:41:27 [4] remove KEEPING the backups -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "Stack prowlarr removed"}
14:41:36 [4] left after remove: containers=0 volumes=0
14:41:36 [R] restoring prowlarr from snapshot 'helyi' (of 1 offered)
14:41:36 [R] POST /backup/restore -> HTTP/2 302 ['location: /backups/restore?flash=flash.restore.started']
14:41:36 + 0.0s restore (True, None, None)
14:41:54 + 18.2s restore (False, None, None)
14:41:54 [R] after restore: state=running hold=None phase=None
14:41:58 [5] restore: {"ok": true, "snapshot_id": "helyi", "http": "HTTP/2 302", "seconds": 18.3, "state_after": "running", "hold_after": null, "why": null}
14:42:11 prowlarr: tag kept, app profile present=True
14:42:11 [5] 2.5 the seed reads back after remove + restore: True
14:42:11 [6] R-1057 a STRANGER after the restore: GET /ping -> 401
14:42:11 [6] R-1057 a STRANGER after the restore: GET / -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fprowlarr.e
14:42:11 [6] R-1057 a STRANGER after the restore: GET /initialize.json -> 401
14:42:11 [6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> 200
14:42:11 [6] gate record after the restore: {"state": "closed", "since": "2026-10-15T12:41:37Z", "hosts": ["prowlarr.enkisfelhom.hu"]}
14:42:14 [6] gate file on the box: /opt/docker/stacks/traefik/dynamic/setup-gate-prowlarr.yml
14:42:14 [7] the household's „Kész, beállítottam” -> 200 {"data": {"opened": true}, "error": "", "ok": true}
14:42:22 [7] a stranger after „Kész”: GET /ping -> 200 (the app itself answers now)
14:42:26 [5] through traefik after restore: 200
14:42:29 [5] app.yaml env keys after restore: DOMAIN SUBDOMAIN
14:42:32 [5] image after restore: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
14:42:35 [X] stop -> 200 {'ok': True, 'message': 'Stack prowlarr stop completed'}
14:43:07 [X] remove (with drive data) -> 200 {'ok': True, 'data': {'removed': 'prowlarr', 'volumes_removed': ['prowlarr_prowlarr_config'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'A
14:43:16 [X] after remove: deployed=False leftovers='/opt/docker/stacks/prowlarr'
14:43:19 [9] after remove: containers=0 volumes=0
@@ -0,0 +1,18 @@
{
"seed": {
"tag": "felhom20fd668a",
"profile": "felhom-profile-c7f82e"
},
"seed_before": true,
"after_update": true,
"after_restore": true,
"update_final": "done",
"restore": {
"ok": true,
"snapshot_id": "helyi",
"http": "HTTP/2 302",
"seconds": 18.3,
"state_after": "running"
},
"measured_at": "2026-10-15T12:43:19Z"
}
@@ -0,0 +1,54 @@
drill commit fd4b9f1541db: -> lscr.io/linuxserver/prowlarr:version-2.5.2.5491 (push rc=0)
[sync] the badge NEVER caught up to lscr.io/linuxserver/prowlarr:version-2.5.2.5491 in 78.5s — catalog_images = None
[1] deploy -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
gate: prowlarr is gated — passed as the household (cookie set)
[1] through traefik: 401 (gated: 302 to the gate is the healthy answer for a stranger)
[1] stack .felhom.yml bytes: 3948
[1] running: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
prowlarr: POST /tag felhom20fd668a http=201
prowlarr: POST /appprofile felhom-profile-c7f82e http=201
prowlarr: tag kept, app profile present=True
[2] seed {'tag': 'felhom20fd668a', 'profile': 'felhom-profile-c7f82e'} read back: True
[2] 2.7 config volume size after the seed: 5.4M
[2] 4.4 negative control, the same probe on a port nothing serves: rc=7 ; the real probe: rc=0
[2] 1.8 the app's own start lines: [Info] Microsoft.Hosting.Lifetime: Application started. Press Ctrl+C to shut down. | [Info] Microsoft.Hosting.Lifetime: Hosting environment: Production
drill commit c42d92bc425d: -> lscr.io/linuxserver/prowlarr:version-2.6.5.5623 (push rc=0)
[6] Update -> 202 {'ok': True, 'data': {'accepted': True, 'completed': False}, 'message': 'Frissítés elindult – az állapot a kártyán követhető'}
+ 0.0s phase=backing-up label=Biztonsági mentés készül a frissítés előtt… err=None hold=None
+ 5.1s phase=safety-dump label=Adatbázis pillanatkép… err=None hold=None
+ 6.2s phase=pulling label=Új verzió letöltése… err=None hold=None
+ 14.4s phase=copying label=Az adatok másolása a frissítés előtt… err=None hold=None
+ 19.5s phase=verifying label=Működés ellenőrzése… err=None hold=None
+ 29.7s phase=done label=Frissítve err=None hold=None
[3] guarded Update (its backing-up phase is the per-app backup): final=done phases=['backing-up', 'safety-dump', 'pulling', 'copying', 'verifying', 'done']
prowlarr: tag kept, app profile present=True
[3] seed read back after the Update: True
[3] after: {"pinned_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "installed_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "catalog_images": {"prowlarr": "lscr.io/linuxserver/prowlarr:version-2.6.5.5623"}, "live_compose_image_lines": ["image: lscr.io/linuxserver/prowlarr:version-2.6.5.5623"], "docker_inspect": ["prowlarr lscr.io/linuxserver/prowlarr:ve
[3] R-1057 the per-app backup's unit app.yaml, its gate lines:
[4] restore points offered BEFORE the remove: [{"time": "2026-10-15T12:40:06Z", "short_id": "helyi", "tier": 1, "drive_label": "Belső SSD (rendszer)"}]
[4] stop -> 200
[4] remove KEEPING the backups -> 200 {"ok": true, "data": {"removed": "prowlarr", "volumes_removed": ["prowlarr_prowlarr_config"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "userdata_kept": [], "verified": true}, "message": "Stack prowlarr removed"}
[4] left after remove: containers=0 volumes=0
[R] restoring prowlarr from snapshot 'helyi' (of 1 offered)
[R] POST /backup/restore -> HTTP/2 302 ['location: /backups/restore?flash=flash.restore.started']
+ 0.0s restore (True, None, None)
+ 18.2s restore (False, None, None)
[R] after restore: state=running hold=None phase=None
[5] restore: {"ok": true, "snapshot_id": "helyi", "http": "HTTP/2 302", "seconds": 18.3, "state_after": "running", "hold_after": null, "why": null}
prowlarr: tag kept, app profile present=True
[5] 2.5 the seed reads back after remove + restore: True
[6] R-1057 a STRANGER after the restore: GET /ping -> 401
[6] R-1057 a STRANGER after the restore: GET / -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fprowlarr.e
[6] R-1057 a STRANGER after the restore: GET /initialize.json -> 401
[6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> 200
[6] gate record after the restore: {"state": "closed", "since": "2026-10-15T12:41:37Z", "hosts": ["prowlarr.enkisfelhom.hu"]}
[6] gate file on the box: /opt/docker/stacks/traefik/dynamic/setup-gate-prowlarr.yml
[7] the household's „Kész, beállítottam” -> 200 {"data": {"opened": true}, "error": "", "ok": true}
[7] a stranger after „Kész”: GET /ping -> 200 (the app itself answers now)
[5] through traefik after restore: 200
[5] app.yaml env keys after restore: DOMAIN SUBDOMAIN
[5] image after restore: lscr.io/linuxserver/prowlarr:version-2.5.2.5491 health=healthy
[X] stop -> 200 {'ok': True, 'message': 'Stack prowlarr stop completed'}
[X] remove (with drive data) -> 200 {'ok': True, 'data': {'removed': 'prowlarr', 'volumes_removed': ['prowlarr_prowlarr_config'], 'hdd_paths_removed': [], 'hdd_paths_preserved': [], 'hdd_note': 'A
[X] after remove: deployed=False leftovers='/opt/docker/stacks/prowlarr'
[9] after remove: containers=0 volumes=0
@@ -0,0 +1,23 @@
# Controller v0.314.0 — the gate on restore, test builds and the floor, Prowlarr's link, Plex by address (2026-10-15)
Design read: `architecture/09-update-architecture.md` §3 decisions 46–49, 245–253; `07-backup-architecture.md`;
`15-media-library.md` §6. Code: felhom-controller `17863fe` + `97c992a`.
| folder / file | what it proves |
|---|---|
| `9202/00-rc1.txt` | scratch guest 9202 on `0.314.0-rc.1` |
| `9202/restore.txt` | **R-1057 live:** Prowlarr installed (no account), the Update's per-app backup, remove keeping backups, restore → a stranger: `/ping` 401, `/` 302 to the gate, `/initialize.json` 401; the household 200; „Kész" opens it |
| `9202/02-controller-log.txt` | the box's own line: „restored with its setup gate CLOSED — it was closed when the backup was taken (R-1057)" |
| `9202/link.txt` | **R-1058 live:** Sonarr, then Prowlarr → „a media app came up: prowlarr", linked ~40 s later, no button; a second run „unchanged" |
| `release/01-tester1-rc1.txt` | Tester 1 put on `0.314.0-rc.1` by hand (rc.4 runs the old check and cannot leave by itself) |
| `release/02-customer-floors.txt` | per-customer floors 0.314.0 / MinAgent 0.155.0 through CC's token: 303 `floor_set` ×3 |
| `release/03-boxes-move.txt` | demo-hp and demo-felhom on 0.314.0 within 13 s; Tester 1 at 13:09:38 by itself (**R-1053 live**), at its regular report (R-1061) |
| `release/04-https-only.txt`, `05-health.txt` | the box walk's checks on all three: https-only OK; 0 unhealthy, 0 restarting, 0 exited; controller healthy; dashboard 200 |
| `release/06-global-floor.txt` | **the global floor 0.314.0, MinAgent 0.155.0, by CC's token:** 303 `floor_set` at 13:11:04 UTC; read back from the hub DB with its WAL |
| `release/07-clear-customer-floors.txt` | the three per-customer floors cleared (empty), read back |
| `plex9201/01-fake-code.txt` | **R-1060 live, partly:** a FAKE code through the dashboard's claim call — Plex took it as local and asked plex.tv (500 after 938 ms; before the fix 401 in 0 ms) |
| `plex9201/02-claim.txt` | the real claim, when the operator's code arrives (watcher) |
| `tools/` | `walk_gate_restore.py`, `walk_link.py`, `repoint9202.py` |
Red-proofs (mutation runs) are quoted in the controller CHANGELOG entry and the report
(`REPORT-gate-restore-release-2026-10-15.md`).
@@ -0,0 +1,8 @@
controller: gitea.dooplex.hu/admin/felhom-controller:0.314.0
dashboard claim with a FAKE code -> 409 {"data": null, "error": "Plex did not accept the code. It may have run out: get a new one at plex.tv/claim.", "ok": false}
2026/10/15 13:14:14 media_events.go:382: [INFO] [web] plex: the household's claim code given to the running server -> HTTP 500 (err <nil>; the code is not logged)
Oct 15, 2026 15:11:43.440 [139838624131896] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 15:14:13.878 [139838519249720] DEBUG - Request: [172.18.0.5:57144 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #866 GZIP
Oct 15, 2026 15:14:14.817 [139838624131896] DEBUG - Completed: [172.18.0.5:57144] 500 POST /myplex/claim?token=<fake> (2 live) #866 GZIP 938ms 405 bytes (pipelined: 1)
@@ -0,0 +1,12 @@
M1 no gate on restore: rc=1 failing=['TestR1057_ClosedAtTheBackupComesBackClosed', 'TestR1057_InPlaceRestoreOfAClosedBackupRegates', 'TestR1057_InPlaceRestoreOfAnOpenBackupOpens', 'TestR1057_NoRecordNoProbeStaysClosed', 'TestR1057_NoRecordProbeDoneOpensByTheLoop', 'TestR1057_NoRecordProbeNotDoneStaysClosed', 'TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts']
M2 every known record is closed: rc=1 failing=['TestR1057_InPlaceRestoreOfAnOpenBackupOpens', 'TestR1057_OpenAtTheBackupComesBackOpen', 'TestR773_ARemovedAppComesBackWithSignupClosed']
M3 no-record case dropped: rc=1 failing=['TestR1057_NoRecordNoProbeStaysClosed', 'TestR1057_NoRecordProbeDoneOpensByTheLoop', 'TestR1057_NoRecordProbeNotDoneStaysClosed']
M4 closed case only for removed: rc=1 failing=['TestR1057_InPlaceRestoreOfAClosedBackupRegates']
M5 never-gated captured as nil: rc=1 failing=['TestR1057_CaptureAndReadBack']
M6 unknown state fails open: rc=1 failing=['TestR1057_CaptureAndReadBack']
M7 reopen case dropped: rc=1 failing=['TestR1057_InPlaceRestoreOfAnOpenBackupOpens']
M8 gate written after nothing (prepare dropped): rc=1 failing=['TestR1057_ClosedAtTheBackupComesBackClosed', 'TestR1057_InPlaceRestoreOfAClosedBackupRegates', 'TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts']
restored: ok gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.049s
M9 no-record closes an installed app too: --- FAIL: TestR1057_InPlaceNoRecordKeepsTheAppsRecord (0.00s)
M10 unit drops the gate: --- FAIL: TestR1057_TheUnitCarriesTheGateAndTheRestoreReadsIt (0.00s)
M10 unit drops the gate: r1057_unit_gate_test.go:33: the restore must read the closed gate back from the unit, got {Known:false Closed:false OpenedBy:}
@@ -0,0 +1,8 @@
M1 comparePre returns 0: rc=1 failing=['TestR1053_ATestBuildIsMovedToTheFloor', 'TestR1053_PreReleaseOrdersBelowItsRelease']
M2 floor parses strictly again: rc=1 failing=['TestR1053_ATestBuildIsMovedToTheFloor']
M3 warn every call: rc=1 failing=['TestR1053_AnUnreadableVersionWarnsOncePerChange']
M4 the button parses pre-releases: rc=1 failing=['TestR1053_TheButtonNeverOffersATestBuild']
M5 a numeric id ordered as text: rc=1 failing=['TestR1053_PreReleaseOrdersBelowItsRelease']
restored: ok gitea.dooplex.hu/admin/felhom-controller/internal/util 0.041s | ok gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate 0.044s
M6 every test build moved: --- FAIL: TestR1053_ATestBuildAboveTheFloorStays (0.00s)
M7 at-floor updates anyway: --- FAIL: TestMaybeAutoUpdate_AtFloor_NoAction (0.00s)
@@ -0,0 +1,2 @@
C-M1 linkOnlyApps empty: --- FAIL: TestR1058_ProwlarrUpRunsTheWiring (0.01s)
C-M1 linkOnlyApps empty: r1058_prowlarr_wires_test.go:36: wiring runs = [sonarr], want [prowlarr sonarr] (Prowlarr's up, Sonarr's up; never another app, never a non-up command)
@@ -0,0 +1,6 @@
C2-M1 Plex by name again: --- FAIL: TestR1060_TheClaimReachesPlexByAddress (0.00s)
C2-M1 Plex by name again: r1060_plex_by_address_test.go:57: claim → 401 (<nil>); Plex refuses the name „plex” as non-local — the box must call it by address
C2-M1 Plex by name again: --- FAIL: TestR1060_ProductionURLCallsPlexByAddress (0.00s)
C2-M1 Plex by name again: plexaddr_test.go:22: plex → "http://plex:32400", want its IPv4 address
C2-M2 wiring by name: --- FAIL: TestR1060_ProductionURLCallsPlexByAddress (0.00s)
C2-M2 wiring by name: plexaddr_test.go:22: plex → "http://plex:32400", want its IPv4 address
@@ -0,0 +1,4 @@
before: gitea.dooplex.hu/admin/felhom-controller:0.313.0-rc.4
17
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1
gitea.dooplex.hu/admin/felhom-controller:0.314.0-rc.1 Up 30 seconds (healthy)
@@ -0,0 +1,3 @@
12:55:51 POST /customers/tester-1/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/tester-1?flash=floor_set
12:55:51 POST /customers/demo-hp/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/demo-hp?flash=floor_set
12:55:51 POST /customers/demo-felhom/floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /customers/demo-felhom?flash=floor_set
@@ -0,0 +1,51 @@
12:56:04 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:56:23 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:56:42 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:01 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:20 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:40 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:57:59 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:18 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:37 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:58:56 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:16 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:35 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
12:59:54 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:13 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:33 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:00:52 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:11 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:30 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:01:49 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:08 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:28 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:02:47 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:06 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:25 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:03:45 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:04 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:23 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:04:42 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:02 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:21 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:40 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:05:59 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:18 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:21 tester-1=0.314.0-rc.1
13:06:37 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:06:43 tester-1=0.314.0-rc.1
13:06:55 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:04 tester-1=0.314.0-rc.1
13:07:14 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:26 tester-1=0.314.0-rc.1
13:07:33 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:07:48 tester-1=0.314.0-rc.1
13:07:51 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:09 tester-1=0.314.0-rc.1
13:08:10 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:30 tester-1=0.314.0-rc.1 demo-hp=0.314.0 demo-felhom=0.314.0
13:08:30 tester-1=0.314.0-rc.1
13:08:52 tester-1=0.314.0-rc.1
13:09:14 tester-1=0.314.0-rc.1
13:09:36 tester-1=0.314.0-rc.1
13:09:58 tester-1=0.314.0
@@ -0,0 +1,35 @@
control NEGATIVE (a stub answering 200 over plain http): convicted, as it must (200 -)
== tester-1 (enkicsifelhom.hu): 13 public name(s)
OK control POSITIVE http://felhom.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkicsifelhom.hu/r1033-check?q=1
OK http://files.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://files.enkicsifelhom.hu/r1033-check?q=1
OK http://media.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://media.enkicsifelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkicsifelhom.hu/r1033-check?q=1
OK http://paperless.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paperless.enkicsifelhom.hu/r1033-check?q=1
OK http://paste.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://paste.enkicsifelhom.hu/r1033-check?q=1
OK http://plex.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://plex.enkicsifelhom.hu/r1033-check?q=1
OK http://prowlarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://prowlarr.enkicsifelhom.hu/r1033-check?q=1
OK http://radarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://radarr.enkicsifelhom.hu/r1033-check?q=1
OK http://sonarr.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://sonarr.enkicsifelhom.hu/r1033-check?q=1
OK http://torrent.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://torrent.enkicsifelhom.hu/r1033-check?q=1
OK http://transmission.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://transmission.enkicsifelhom.hu/r1033-check?q=1
OK http://wiki.enkicsifelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkicsifelhom.hu/r1033-check?q=1
== demo-hp (enkisfelhom.hu): 13 public name(s)
OK http://books.enkisfelhom.hu/r1033-check?q=1 -> 301 https://books.enkisfelhom.hu/r1033-check?q=1
OK http://docs.enkisfelhom.hu/r1033-check?q=1 -> 301 https://docs.enkisfelhom.hu/r1033-check?q=1
OK http://felhom.enkisfelhom.hu/r1033-check?q=1 -> 301 https://felhom.enkisfelhom.hu/r1033-check?q=1
OK http://files.enkisfelhom.hu/r1033-check?q=1 -> 301 https://files.enkisfelhom.hu/r1033-check?q=1
OK http://gist.enkisfelhom.hu/r1033-check?q=1 -> 301 https://gist.enkisfelhom.hu/r1033-check?q=1
OK http://ha.enkisfelhom.hu/r1033-check?q=1 -> 301 https://ha.enkisfelhom.hu/r1033-check?q=1
OK http://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1 -> 301 https://no-such-name-r1033.enkisfelhom.hu/r1033-check?q=1
OK http://papir.enkisfelhom.hu/r1033-check?q=1 -> 301 https://papir.enkisfelhom.hu/r1033-check?q=1
OK http://paste.enkisfelhom.hu/r1033-check?q=1 -> 301 https://paste.enkisfelhom.hu/r1033-check?q=1
OK http://pdf.enkisfelhom.hu/r1033-check?q=1 -> 301 https://pdf.enkisfelhom.hu/r1033-check?q=1
OK http://plex.enkisfelhom.hu/r1033-check?q=1 -> 301 https://plex.enkisfelhom.hu/r1033-check?q=1
OK http://travel.enkisfelhom.hu/r1033-check?q=1 -> 301 https://travel.enkisfelhom.hu/r1033-check?q=1
OK http://wiki.enkisfelhom.hu/r1033-check?q=1 -> 301 https://wiki.enkisfelhom.hu/r1033-check?q=1
== demo-felhom (demo-felhom.eu): 4 public name(s)
OK http://felhom.demo-felhom.eu/r1033-check?q=1 -> 301 https://felhom.demo-felhom.eu/r1033-check?q=1
OK http://files.demo-felhom.eu/r1033-check?q=1 -> 301 https://files.demo-felhom.eu/r1033-check?q=1
OK http://gist.demo-felhom.eu/r1033-check?q=1 -> 301 https://gist.demo-felhom.eu/r1033-check?q=1
OK http://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1 -> 301 https://no-such-name-r1033.demo-felhom.eu/r1033-check?q=1
== every public name redirects http -> https
@@ -0,0 +1,15 @@
== tester-1
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=17 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== demo-hp
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=21 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== demo-felhom
gitea.dooplex.hu/admin/felhom-controller:0.314.0
containers=5 unhealthy=0 restarting=0 exited=0
controller ERROR lines last 10m: 0
== tester-1 (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
== demo-hp (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
== demo-felhom (2nd pass) controller-docker-health=healthy dashboard-via-traefik=200
@@ -0,0 +1 @@
2026-10-15T13:11:04Z POST /configuration/global-floor 0.314.0 MinAgent 0.155.0 -> HTTP/1.1 303 See Other Location: /configuration?flash=floor_set
@@ -0,0 +1,9 @@
13:13:43 POST /customers/tester-1/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/tester-1?flash=floor_set
13:13:43 POST /customers/demo-hp/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/demo-hp?flash=floor_set
13:13:43 POST /customers/demo-felhom/floor (empty = clear) -> HTTP/1.1 303 See Other Location: /customers/demo-felhom?flash=floor_set
demo-felhom|''
demo-hp|''
tester-1|''
Tester-2|''
min_controller_version|0.314.0
min_controller_version_declared_min_agent|0.314.0=0.155.0
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""repoint.py mirror|drill|restore|status — point the Tester 1 box at the drill catalog WITHOUT a credential (R-992).
WHY A LOCAL MIRROR. `09` §6.5's route needs a catalog credential on the box: the drill repo is private (R-992). This
session could not mint a READ-ONLY one — the Gitea credential on DooPlex is itself a token, and Gitea answers 403 to a
token asking to list or create tokens (measured 2026-10-14) — and the night of 2026-10-06 put the admin WRITE token
into the box's controller.yaml, which this task forbids. The cache-staging workaround of 2026-10-12 does not hold for a
test that lasts days: every sync (and every controller start) re-applies the live catalog over the staged templates
and over the installed apps' compose files (Syncer.copyTemplates, no deployed check).
So the drill catalog is MIRRORED onto the box: a bare git repository inside the controller's own data volume, and
`git.repo_url: file:///opt/docker/felhom-controller/drill-catalog.git`. The controller's sync runs exactly as for a
remote (clone/fetch/reset, the R-615 re-clone on a changed URL); no secret is on the box. DooPlex updates the mirror
from the drill repo with a git bundle (`mirror`). The drill repo on Gitea stays the record.
SC=~/.felhom-tester1 TARGET=tester-1 python3 repoint.py mirror # (re)load the mirror from the drill repo main
... repoint.py drill # save controller.yaml (once), set repo_url to the mirror, restart, sync, rescan
... repoint.py restore # put the saved controller.yaml back (byte-identical), restart, sync, rescan
... repoint.py status
"""
import os, subprocess, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
DRILL = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill"
VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" # guest path of the controller's data volume
CVOL = "/opt/docker/felhom-controller" # the same, inside the controller container
SAVE = f"{VOL}/controller.yaml.pre-prowlarr1015"
MIRROR_URL = f"file://{CVOL}/drill-catalog.git"
def status():
print(w.guest(f"""grep -nE 'repo_url|sync_interval' {VOL}/controller.yaml; ls -l {SAVE} 2>&1 | tail -1
docker exec felhom-controller sh -c 'git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main 2>&1;
git -C {CVOL}/data/catalog-cache log -1 --format="cache HEAD %h %s" 2>&1; git -C {CVOL}/data/catalog-cache remote get-url origin'"""))
def mirror():
b = "/tmp/drill-prowlarr.bundle"
subprocess.run(["git", "-C", DRILL, "bundle", "create", b, "main"], check=True, capture_output=True)
head = subprocess.run(["git", "-C", DRILL, "rev-parse", "--short", "main"], capture_output=True, text=True).stdout.strip()
ssh = w.ssh_args()
# bundle -> PVE host -> guest -> controller volume; the bare repo is made and fetched BY the controller's own user
subprocess.run(["scp", "-q", "-o", "StrictHostKeyChecking=accept-new"] + (["-J", w._T["jump"]] if w._T["jump"] else [])
+ [b, f"{w._T['ssh']}:{b}"], check=True)
subprocess.run(ssh + [f"pct push {w.GUEST} {b} {VOL}/drill-prowlarr.bundle && rm -f {b}"], check=True)
print(w.guest(f"""set -e
docker exec felhom-controller sh -c 'test -d {CVOL}/drill-catalog.git || git init -q --bare -b main {CVOL}/drill-catalog.git
git -C {CVOL}/drill-catalog.git fetch -q {CVOL}/drill-prowlarr.bundle +main:main && rm -f {CVOL}/drill-prowlarr.bundle
git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main'"""))
print("drill repo main:", head)
os.remove(b)
def restart_sync():
w.guest("docker restart felhom-controller >/dev/null")
for _ in range(60):
time.sleep(3)
try:
w.login()
break
except SystemExit:
continue
w.sync_rescan()
time.sleep(3)
print(w.guest("docker logs --since 3m felhom-controller 2>&1 | grep -E '\\[sync\\]' | grep -v DEBUG | tail -8"))
def drill():
print(w.guest(f"""set -e
test -f {SAVE} || cp -p {VOL}/controller.yaml {SAVE}
sed -i -E 's#^(\\s+repo_url: ).*$#\\1{MIRROR_URL}#' {VOL}/controller.yaml
grep -n 'repo_url' {VOL}/controller.yaml; diff {SAVE} {VOL}/controller.yaml || true"""))
restart_sync()
status()
def restore():
print(w.guest(f"""set -e
cp -p {SAVE} {VOL}/controller.yaml
cmp {SAVE} {VOL}/controller.yaml && echo RESTORED-IDENTICAL; grep -n repo_url {VOL}/controller.yaml"""))
restart_sync()
status()
{"mirror": mirror, "drill": drill, "restore": restore, "status": status}[sys.argv[1]]()
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""walk_gate_restore.py (from prowlarr-2026-10-15/tools/walk_restore.py) — R-1057 live proof on controller 0.314.0-rc.1, and
NEW-APP-CHECKLIST 2.5 for Prowlarr on scratch guest 9202: seed -> backup -> remove -> restore ->
read back, THROUGH THE PRODUCT (R-1048, 2026-10-15, second 9202 run).
The box has no per-app „Mentés most" (R-648: the only backup press is whole-box and is never pressed from a drill), so
the per-app backup is the one the guarded Update takes in its `backing-up` phase — the same route as grocy's and
lubelogger's restore records. Runs ON DOOPLEX with TARGET=9202, SC=<0600 dir holding .ctlpw>, EV=<evidence dir>; 9202
must already read the drill mirror, whose prowlarr template is at FROM. Presses: deploy, the drill bump + Update,
remove KEEPING the backups, the household's restore button (POST /backup/restore), remove with the backups.
"""
import json
import os
import re
import subprocess
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"
TOOLS = os.path.dirname(os.path.abspath(__file__))
LOG = open(os.path.join(EV, "restore.txt"), "a", buffering=1)
_lib_say = w.say
def say(*a):
_lib_say(*a)
LOG.write(" ".join(str(x) for x in a) + "\n")
w.say = say
def drill_set(ref, why):
"""Point the drill template at `ref`, staged by explicit path, then reload the 9202 mirror."""
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()
other = TO if ref == FROM else FROM
if f"image: {ref}" not in c_:
open(comp, "w").write(c_.replace(f"image: {other}", f"image: {ref}"))
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}: {why} -> {ref} (R-1048, checklist 2.5)"])
r_ = w.sh(["git", "-C", D, "push", "-q", "origin", "main"], timeout=120)
say(f"drill commit {w.sh(['git', '-C', D, 'rev-parse', '--short=12', 'HEAD']).stdout.strip()}: -> {ref} (push rc={r_.returncode})")
subprocess.run([sys.executable, os.path.join(TOOLS, "repoint9202.py"), "mirror"], check=True, env=dict(os.environ))
def main():
w.login()
if w.stack(APP).get("deployed"):
sys.exit(say("prowlarr is installed on 9202 — STOP, not a fresh install") or 1)
drill_set(FROM, "back to the previous image for a step")
w.sync_rescan(expect_app=APP, expect_ref=FROM)
code, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": w.DOMAIN, "SUBDOMAIN": SUB}})
say(f"[1] deploy -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
w.DEPLOYED_THIS_RUN.add(APP)
for _ in range(60):
s = w.stack(APP)
if s.get("deployed") and s.get("state") == "running":
break
time.sleep(3)
w.wait_app(SUB, "/ping", want=("200",), tries=40)
say("[1] through traefik: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip()
+ " (gated: 302 to the gate is the healthy answer for a stranger)")
say("[1] stack .felhom.yml bytes: " + w.guest(f"wc -c < /opt/docker/stacks/{APP}/.felhom.yml").strip())
say("[1] running: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip())
fxp = fx.FIXTURES[APP]
tok = fxp.seed(w, SUB, say)
ok1 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[2] seed {tok} read back: {ok1}")
say("[2] 2.7 config volume size after the seed: " + w.guest("du -sh /var/lib/docker/volumes/prowlarr_prowlarr_config/_data | cut -f1").strip())
say("[2] 4.4 negative control, the same probe on a port nothing serves: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9697/ping; echo $?").strip()
+ " ; the real probe: rc=" + w.guest("docker exec prowlarr curl -sf -o /dev/null http://127.0.0.1:9696/ping; echo $?").strip())
say("[2] 1.8 the app's own start lines: " + w.guest("docker logs prowlarr 2>&1 | grep -m2 -E 'Hosting environment|Application started'").strip().replace("\n", " | "))
drill_set(TO, "the step")
w.sync_rescan(expect_app=APP, expect_ref=TO)
up = w.press_update(APP)
say(f"[3] guarded Update (its backing-up phase is the per-app backup): final={up.get('final_phase')} "
f"phases={[p.get('phase') for p in up.get('phases', [])]}")
ok2 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[3] seed read back after the Update: {ok2}")
obs = w.observables(APP)
say(f"[3] after: {json.dumps(obs, ensure_ascii=False)[:400]}")
s = w.stack(APP)
if os.environ.get("VERDICT") == "1":
verdict = {"app": APP, "venue": "box 9202 (drill catalog), the product's guarded Update — run 3, the full .felhom.yml",
"from": {APP: FROM}, "to": {APP: TO},
"verdict": "proven" if (ok1 and ok2 and up.get("final_phase") == "done" and s.get("state") == "running") else "failed",
"seed_read_before": ok1, "seed_read_after": ok2, "healthy_after": s.get("state") == "running",
"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/gate-restore-2026-10-15/9202/restore.txt"}
os.makedirs(os.path.join(EV, "..", APP), exist_ok=True)
json.dump(verdict, open(os.path.join(EV, "..", APP, "box-verdict-prowlarr.json"), "w"), indent=2)
say(f"[3] box verdict {verdict['verdict']}")
say("[3] R-1057 the per-app backup's unit app.yaml, its gate lines: " + w.guest(
f"f=$(find / -xdev -path '*{APP}*' -name app.yaml -path '*compose*' -newer /etc/felhom-controller-image 2>/dev/null | head -3); "
"for x in $f; do echo \"$x: $(grep -A2 '^setup_gate:' $x | tr '\\n' ' ')\"; done").strip())
snaps = w.snapshots(APP)
say(f"[4] restore points offered BEFORE the remove: {json.dumps(snaps, ensure_ascii=False)[:400]}")
c1, d1 = w.ctl("POST", f"/api/stacks/{APP}/stop")
say(f"[4] stop -> {c1}")
for _ in range(24):
time.sleep(5)
if w.stack(APP).get("state") != "running":
break
code, d = w.ctl("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": False, "remove_backups": False})
say(f"[4] remove KEEPING the backups -> {code} {json.dumps(d, ensure_ascii=False)[:300]}")
time.sleep(5)
say("[4] left after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr)").strip())
rr = w.restore(APP)
say(f"[5] restore: {json.dumps({k: rr.get(k) for k in ('ok', 'snapshot_id', 'http', 'seconds', 'state_after', 'hold_after', 'why')}, ensure_ascii=False)}")
w.wait_app(SUB, "/ping", want=("200",), tries=40)
ok3 = bool(tok) and fxp.verify(w, SUB, tok, say)
say(f"[5] 2.5 the seed reads back after remove + restore: {ok3}")
for p, acc in (("/ping", "application/json"), ("/", "text/html"), ("/initialize.json", "application/json")):
r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-D", "-", "-H", f"Host: {SUB}.{w.DOMAIN}",
"-H", f"Accept: {acc}", f"{w.BASE}{p}"])
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or ""); loc = re.search(r"(?im)^location:\s*(\S+)", r.stdout or "")
say(f"[6] R-1057 a STRANGER after the restore: GET {p} -> {st.group(1) if st else '000'} {('loc=' + loc.group(1)[:70]) if loc else ''}")
rc, code, _ = w.app_curl(SUB, "/ping", timeout=20)
say(f"[6] the HOUSEHOLD (gate cookie) after the restore: GET /ping -> {code}")
st = w.stack(APP)
say(f"[6] gate record after the restore: {json.dumps((st.get('app_config') or {}).get('setup_gate'))}")
say("[6] gate file on the box: " + w.guest(f"ls /opt/docker/stacks/traefik/dynamic/setup-gate-{APP}.yml 2>&1").strip())
code, d = w.ctl("POST", f"/apps/{APP}/setup-gate/open")
say(f"[7] the household's „Kész, beállítottam” -> {code} {json.dumps(d, ensure_ascii=False)[:120]}")
time.sleep(8)
r = w.sh(["curl", "-sSk", "--max-time", "20", "-o", "/dev/null", "-w", "%{http_code}", "-H", f"Host: {SUB}.{w.DOMAIN}", f"{w.BASE}/ping"])
say(f"[7] a stranger after „Kész”: GET /ping -> {r.stdout} (the app itself answers now)")
say("[5] through traefik after restore: " + w.guest(f"curl -sk -o /dev/null -w '%{{http_code}}' -H 'Host: {SUB}.{w.DOMAIN}' https://127.0.0.1/ping").strip())
say("[5] app.yaml env keys after restore: " + w.guest(f"sed -n '/^env:/,/^[a-z]/p' /opt/docker/stacks/{APP}/app.yaml | grep -oE '^ +[A-Z_]+' | paste -sd' '").strip())
say("[5] image after restore: " + w.guest("docker inspect prowlarr -f '{{.Config.Image}} health={{.State.Health.Status}}'").strip())
# teardown through the product, backups too (this is a scratch install)
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)").strip())
json.dump({"seed": tok, "seed_before": ok1, "after_update": ok2, "after_restore": ok3,
"update_final": up.get("final_phase"), "restore": {k: rr.get(k) for k in ("ok", "snapshot_id", "http", "seconds", "state_after")},
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
open(os.path.join(EV, "restore-results.json"), "w"), indent=2, ensure_ascii=False)
main()
@@ -0,0 +1,141 @@
#!/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()
@@ -0,0 +1,9 @@
Host plex: claim(fake code)=401 identity=200 sections-no-token=401
Host 172.18.0.15: claim(fake code)=500 identity=200 sections-no-token=200
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Request: [172.18.0.5:52552 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #700
Oct 15, 2026 14:22:19.292 [139838622022456] DEBUG - Completed: [172.18.0.5:52552] 401 POST /myplex/claim?token=<fake> (2 live) #700 0ms 268 bytes
Oct 15, 2026 14:22:19.378 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.463 [139838622022456] DEBUG - Request came in with unrecognized domain / IP 'plex' in header Host; treating as non-local
Oct 15, 2026 14:22:19.552 [139838519249720] DEBUG - Request: [172.18.0.5:52584 (Subnet)] POST /myplex/claim?token=<fake> (2 live) #708
Oct 15, 2026 14:22:20.519 [139838622022456] DEBUG - Completed: [172.18.0.5:52584] 500 POST /myplex/claim?token=<fake> (2 live) #708 966ms 325 bytes (pipelined: 1)