From 4c7465d00d43cf30ac0317cda31a4bc1be4fe1ed Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 4 Sep 2026 12:54:28 -0700 Subject: [PATCH] arr fleet: FrankBook-Pro is the sole deployment, all egress VPN-gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FrankBook-Pro becomes the only *arr/qBittorrent deployment. Every fleet container (qB + five arrs) now shares gluetun's netns, so all egress — torrent, indexer APIs, metadata lookups — exits the WireGuard tunnel by construction (AllowedIPs=0.0.0.0/0); the arrs' own ports publish on the gluetun unit, none on the arr units themselves. - dankserver/compose.yaml: remove the six arr services (the Aug 20 compose copy — never healthy, superseded; config dirs trashed on the host) - dankclaw/Caddyfile + shared/homepage/services.json: arr vhosts and probes repoint 192.168.0.235 -> 192.168.0.112 (FrankBook-Pro) - frankbook-pro units: five arrs join gluetun netns (Wants/After/Network); gluetun publishes 9696/8989/7878/8686/8787 alongside 8080/6881 - tests/frankbook_quadlet.bats: new VPN egress seam — every arr+qB unit must share gluetun's netns and publish no port; gluetun must publish the whole fleet's ports - deploy-plan.py: gluetun changes carry the whole netns family (a gluetun recreation kills all six joiners); dankserver compose set loses the arrs - AGENTS.md: deployment is complete only when unit tests AND the Playwright E2E suite both pass --- AGENTS.md | 12 ++ dankclaw/Caddyfile | 24 ++-- dankserver/.env.example | 2 - dankserver/compose.yaml | 103 ------------------ .../gluetun/quadlet/gluetun.container | 16 ++- frankbook-pro/lidarr/quadlet/lidarr.container | 7 +- .../prowlarr/quadlet/prowlarr.container | 11 +- frankbook-pro/radarr/quadlet/radarr.container | 7 +- .../readarr/quadlet/readarr.container | 7 +- frankbook-pro/sonarr/quadlet/sonarr.container | 7 +- scripts/deploy-plan.py | 14 +-- shared/homepage/services.json | 12 +- tests/deployment_scoping.bats | 4 +- tests/frankbook_quadlet.bats | 38 ++++--- 14 files changed, 101 insertions(+), 163 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2bec806..65331e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,18 @@ Set `FRANK_SUDO_PW` for headless runs on a host without passwordless sudo (remote sudo reads it from stdin via `sudo -S`). The value is never stored in this repo. +### Deployment completeness (MANDATORY) + +A deployment is **not complete** when `deploy.sh` exits green. It is complete +only when BOTH of the following pass: + +1. **Unit/config tests** — `./tests/run.sh` (all seams green), and +2. **Playwright end-to-end tests** — `tests/e2e/run.sh` drives the LIVE fleet + (homepage, Anubis/Authentik guard chain, guarded vhosts) in a real + browser. The E2E suite is deliberately excluded from `./tests/run.sh` + because it needs live hosts and credentials, so it MUST be invoked + explicitly after every behavior-affecting deploy. + ```mermaid graph LR A[Edit locally] --> B[Tests pass] diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index edd8884..f910d5c 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -241,27 +241,27 @@ jelly.franksdank.cloud { # --- *arr Stack (exempt by construction: own auth) --- prowlarr.franksdank.cloud { - reverse_proxy 192.168.0.235:9696 + reverse_proxy 192.168.0.112:9696 } radarr.franksdank.cloud { - reverse_proxy 192.168.0.235:7878 + reverse_proxy 192.168.0.112:7878 } sonarr.franksdank.cloud { - reverse_proxy 192.168.0.235:8989 + reverse_proxy 192.168.0.112:8989 } lidarr.franksdank.cloud { - reverse_proxy 192.168.0.235:8686 + reverse_proxy 192.168.0.112:8686 } readarr.franksdank.cloud { - reverse_proxy 192.168.0.235:8787 + reverse_proxy 192.168.0.112:8787 } qbittorrent.franksdank.cloud { - reverse_proxy 192.168.0.235:8080 + reverse_proxy 192.168.0.112:8080 } # --- Internal listener: auth decision layer --------------------------------- @@ -354,32 +354,32 @@ http://:8017 { @prowlarr host prowlarr.franksdank.cloud handle @prowlarr { - reverse_proxy 192.168.0.235:9696 + reverse_proxy 192.168.0.112:9696 } @radarr host radarr.franksdank.cloud handle @radarr { - reverse_proxy 192.168.0.235:7878 + reverse_proxy 192.168.0.112:7878 } @sonarr host sonarr.franksdank.cloud handle @sonarr { - reverse_proxy 192.168.0.235:8989 + reverse_proxy 192.168.0.112:8989 } @lidarr host lidarr.franksdank.cloud handle @lidarr { - reverse_proxy 192.168.0.235:8686 + reverse_proxy 192.168.0.112:8686 } @readarr host readarr.franksdank.cloud handle @readarr { - reverse_proxy 192.168.0.235:8787 + reverse_proxy 192.168.0.112:8787 } @qbittorrent host qbittorrent.franksdank.cloud handle @qbittorrent { - reverse_proxy 192.168.0.235:8080 + reverse_proxy 192.168.0.112:8080 } } diff --git a/dankserver/.env.example b/dankserver/.env.example index 6a6f4d1..c6e90e9 100644 --- a/dankserver/.env.example +++ b/dankserver/.env.example @@ -20,5 +20,3 @@ COPAY_PASS=changeme # Jellyfin JELLYFIN_PUBLISH_SERVER_URL=http://192.168.0.235:8096 -# *arr Stack -ARR_TZ=America/New_York diff --git a/dankserver/compose.yaml b/dankserver/compose.yaml index d151e32..3ccdf10 100644 --- a/dankserver/compose.yaml +++ b/dankserver/compose.yaml @@ -146,109 +146,6 @@ services: timeout: 10s retries: 3 - # ═══════════════════════════════════════════════ - # *arr Stack — automated media download - # ═══════════════════════════════════════════════ - prowlarr: - image: lscr.io/linuxserver/prowlarr:latest - container_name: prowlarr - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /mnt/entropy/downloads:/downloads:ro - - /opt/prowlarr/config:/config - networks: [appnet] - ports: - - "9696:9696" - - qbittorrent: - image: lscr.io/linuxserver/qbittorrent:latest - container_name: qbittorrent - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /opt/qbittorrent/config:/config - - /mnt/entropy/downloads:/downloads - - /mnt/entropy/downloads/incomplete:/downloads/incomplete - ports: - - "8080:8080" - networks: [appnet] - - radarr: - image: lscr.io/linuxserver/radarr:latest - container_name: radarr - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /opt/radarr/config:/config - - /mnt/entropy/Movies:/movies - - /mnt/entropy/downloads:/downloads - ports: - - "7878:7878" - networks: [appnet] - - sonarr: - image: lscr.io/linuxserver/sonarr:latest - container_name: sonarr - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /opt/sonarr/config:/config - - /mnt/entropy/TVShows:/tv - - /mnt/entropy/downloads:/downloads - ports: - - "8989:8989" - networks: [appnet] - - lidarr: - image: lscr.io/linuxserver/lidarr:latest - container_name: lidarr - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /opt/lidarr/config:/config - - /mnt/entropy/Music:/music - - /mnt/entropy/downloads:/downloads - ports: - - "8686:8686" - networks: [appnet] - - readarr: - image: lscr.io/linuxserver/readarr:amd64-1.0.980 - container_name: readarr - restart: unless-stopped - environment: - - PUID=0 - - PGID=0 - - TZ=America/Los_Angeles - - UMASK_SET=022 - volumes: - - /opt/readarr/config:/config - - /mnt/entropy/eBooks:/books - - /mnt/entropy/downloads:/downloads - ports: - - "8787:8787" - networks: [appnet] # ═══════════════════════════════════════════════ # Node Exporter — host metrics diff --git a/frankbook-pro/gluetun/quadlet/gluetun.container b/frankbook-pro/gluetun/quadlet/gluetun.container index f1bc228..c7934cd 100644 --- a/frankbook-pro/gluetun/quadlet/gluetun.container +++ b/frankbook-pro/gluetun/quadlet/gluetun.container @@ -5,11 +5,12 @@ # (installed by the frankbook-pro handler in scripts/deploy-remote.sh — # install_quadlet targets the Linux hosts' systemctl --user, not the VM). # -# WireGuard tunnel to ProtonVPN. qBittorrent shares this container's network -# namespace (Network=gluetun.container) so torrent egress never touches the -# residential IP. qBittorrent's WebUI and BitTorrent ports publish HERE — a -# netns-sharer publishes no ports of its own (same invariant as the dankclaw -# searxng/gluetun pair). Credentials live in +# WireGuard tunnel to ProtonVPN. qBittorrent and all five *arrs share this +# container's network namespace (Network=gluetun.container) so every fleet +# container's egress — torrent traffic, indexer APIs, metadata lookups — +# exits ONLY through the tunnel (AllowedIPs=0.0.0.0/0). Every fleet port +# publishes HERE; a netns-sharer publishes no ports of its own (same +# invariant as the dankclaw searxng/gluetun pair). Credentials live in # /home/core/Containers/gluetun/env inside the VM (mode 0600, never # committed); the private key is Mac-tunnel-specific and MUST NOT be copied # from dankclaw — one WireGuard peer key cannot serve two machines at once. @@ -33,6 +34,11 @@ Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPA PublishPort=8080:8080 PublishPort=6881:6881 PublishPort=6881:6881/udp +PublishPort=9696:9696 +PublishPort=8989:8989 +PublishPort=7878:7878 +PublishPort=8686:8686 +PublishPort=8787:8787 [Service] EnvironmentFile=/home/core/Containers/gluetun/env diff --git a/frankbook-pro/lidarr/quadlet/lidarr.container b/frankbook-pro/lidarr/quadlet/lidarr.container index 6e1ba2a..1b6a2e4 100644 --- a/frankbook-pro/lidarr/quadlet/lidarr.container +++ b/frankbook-pro/lidarr/quadlet/lidarr.container @@ -3,10 +3,15 @@ # Install path is INSIDE the machine VM: # /home/core/.config/containers/systemd/lidarr.container # +# +# Joins gluetun's network namespace: all egress exits through the WireGuard +# tunnel; 8686 publishes on the gluetun unit. # Grab-only: no media binds; config migrated from dankserver's retired # bare container. [Unit] Description=Lidarr (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service [Container] ContainerName=lidarr @@ -16,7 +21,7 @@ Environment=PGID=0 Environment=TZ=America/Los_Angeles Environment=UMASK_SET=022 Volume=/Volumes/COMPLETE/lidarr-data:/config -PublishPort=8686:8686 +Network=gluetun.container [Service] TimeoutStartSec=900 diff --git a/frankbook-pro/prowlarr/quadlet/prowlarr.container b/frankbook-pro/prowlarr/quadlet/prowlarr.container index 5cff9c5..dc5bf3a 100644 --- a/frankbook-pro/prowlarr/quadlet/prowlarr.container +++ b/frankbook-pro/prowlarr/quadlet/prowlarr.container @@ -3,6 +3,10 @@ # Install path is INSIDE the machine VM: # /home/core/.config/containers/systemd/prowlarr.container # +# +# Joins gluetun's network namespace: all egress (indexer APIs, metadata +# lookups) exits through the WireGuard tunnel; 9696 publishes on the +# gluetun unit. Same-netns targets (qB, the arrs) are loopback addresses. # Arr on the Mac is grab-only: indexer + app-sync management lives here, the # arrs never auto-import media (the user reviews completed torrents in # /Volumes/COMPLETE/"Finished Torrents" and moves files manually into the @@ -11,9 +15,12 @@ # Config is migrated from dankserver's retired bare container # (tar-archived first, per the Phase 4 cutover note) — indexers and app # settings survive; stale host pointers (old qbit at 192.168.0.235:8080) -# are re-pointed to the Mac's qbit via its API after first boot. +# are re-pointed to loopback qBittorrent (127.0.0.1:8080, same netns) via +# its API after first boot. [Unit] Description=Prowlarr (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service [Container] ContainerName=prowlarr @@ -23,7 +30,7 @@ Environment=PGID=0 Environment=TZ=America/Los_Angeles Environment=UMASK_SET=022 Volume=/Volumes/COMPLETE/prowlarr-data:/config -PublishPort=9696:9696 +Network=gluetun.container [Service] TimeoutStartSec=900 diff --git a/frankbook-pro/radarr/quadlet/radarr.container b/frankbook-pro/radarr/quadlet/radarr.container index 1ad3410..ca81b16 100644 --- a/frankbook-pro/radarr/quadlet/radarr.container +++ b/frankbook-pro/radarr/quadlet/radarr.container @@ -3,10 +3,15 @@ # Install path is INSIDE the machine VM: # /home/core/.config/containers/systemd/radarr.container # +# +# Joins gluetun's network namespace: all egress exits through the WireGuard +# tunnel; 7878 publishes on the gluetun unit. # Grab-only: no media binds; config migrated from dankserver's retired # bare container. [Unit] Description=Radarr (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service [Container] ContainerName=radarr @@ -16,7 +21,7 @@ Environment=PGID=0 Environment=TZ=America/Los_Angeles Environment=UMASK_SET=022 Volume=/Volumes/COMPLETE/radarr-data:/config -PublishPort=7878:7878 +Network=gluetun.container [Service] TimeoutStartSec=900 diff --git a/frankbook-pro/readarr/quadlet/readarr.container b/frankbook-pro/readarr/quadlet/readarr.container index d6351c5..60cf6c4 100644 --- a/frankbook-pro/readarr/quadlet/readarr.container +++ b/frankbook-pro/readarr/quadlet/readarr.container @@ -3,6 +3,9 @@ # Install path is INSIDE the machine VM: # /home/core/.config/containers/systemd/readarr.container # +# +# Joins gluetun's network namespace: all egress exits through the WireGuard +# tunnel; 8787 publishes on the gluetun unit. # Grab-only: no media binds; config migrated from dankserver's retired # bare container. # @@ -11,6 +14,8 @@ # of a broken 1.x nightly for Readarr; the pin avoids surprises. [Unit] Description=Readarr (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service [Container] ContainerName=readarr @@ -20,7 +25,7 @@ Environment=PGID=0 Environment=TZ=America/Los_Angeles Environment=UMASK_SET=022 Volume=/Volumes/COMPLETE/readarr-data:/config -PublishPort=8787:8787 +Network=gluetun.container [Service] TimeoutStartSec=900 diff --git a/frankbook-pro/sonarr/quadlet/sonarr.container b/frankbook-pro/sonarr/quadlet/sonarr.container index 4358df1..6d4ffcd 100644 --- a/frankbook-pro/sonarr/quadlet/sonarr.container +++ b/frankbook-pro/sonarr/quadlet/sonarr.container @@ -3,11 +3,16 @@ # Install path is INSIDE the machine VM: # /home/core/.config/containers/systemd/sonarr.container # +# +# Joins gluetun's network namespace: all egress exits through the WireGuard +# tunnel; 8989 publishes on the gluetun unit. # Grab-only: no media binds (the TV library lives on dankserver's entropy # disk, which the Mac never mounts); config migrated from dankserver's # retired bare container. [Unit] Description=Sonarr (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service [Container] ContainerName=sonarr @@ -17,7 +22,7 @@ Environment=PGID=0 Environment=TZ=America/Los_Angeles Environment=UMASK_SET=022 Volume=/Volumes/COMPLETE/sonarr-data:/config -PublishPort=8989:8989 +Network=gluetun.container [Service] TimeoutStartSec=900 diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 5f5e1d5..4dc9703 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -147,12 +147,6 @@ DANKSERVER_COMPOSE_SERVICES = { "copyparty", "jellyfin", "calibre-web", - "prowlarr", - "qbittorrent", - "radarr", - "sonarr", - "lidarr", - "readarr", } COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"} @@ -341,9 +335,11 @@ def services_for_path(path: str, target: str) -> set[str]: if len(parts) >= 3 and parts[2] == "quadlet": name = parts[3].removesuffix(".container") if name == "gluetun": - # qBittorrent shares gluetun's network namespace; a gluetun - # recreation requires both units to be restarted. - _add(services, "gluetun", "qbittorrent") + # Every *arr + qBittorrent container shares gluetun's + # network namespace; a gluetun recreation kills all of + # them, so the plan restarts the whole netns family. + _add(services, "gluetun", "qbittorrent", + "prowlarr", "sonarr", "radarr", "lidarr", "readarr") elif name in FRANKBOOK_SERVICES: _add(services, name) return services diff --git a/shared/homepage/services.json b/shared/homepage/services.json index 7d5065d..5ca7037 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -104,7 +104,7 @@ "description": "TV series library and automation.", "url": "http://sonarr.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:8989", + "probe": "192.168.0.112:8989", "method": "http", "wip": false, "showLink": true @@ -259,7 +259,7 @@ "description": "Indexer manager.", "url": "http://prowlarr.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:9696", + "probe": "192.168.0.112:9696", "method": "http", "wip": false, "showLink": true @@ -270,7 +270,7 @@ "description": "Movie library and automation.", "url": "http://radarr.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:7878", + "probe": "192.168.0.112:7878", "method": "http", "wip": false, "showLink": true @@ -281,7 +281,7 @@ "description": "Book library and automation.", "url": "http://readarr.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:8787", + "probe": "192.168.0.112:8787", "method": "http", "wip": false, "showLink": true @@ -292,7 +292,7 @@ "description": "Music library and automation.", "url": "http://lidarr.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:8686", + "probe": "192.168.0.112:8686", "method": "http", "wip": false, "showLink": true @@ -303,7 +303,7 @@ "description": "Torrent client.", "url": "http://qbittorrent.franksdank.cloud", "docs": "", - "probe": "192.168.0.235:8080", + "probe": "192.168.0.112:8080", "method": "http", "wip": false, "showLink": true diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats index 7339170..adbc857 100644 --- a/tests/deployment_scoping.bats +++ b/tests/deployment_scoping.bats @@ -111,11 +111,11 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" [ "$output" = $'gitea-postgres\ngitea-server' ] } -@test "frankbook-pro gluetun changes carry qbittorrent; arr changes stay scoped" { +@test "frankbook-pro gluetun changes carry the whole netns family; arr changes stay scoped" { run python3 "$PLANNER" --target frankbook-pro --paths \ frankbook-pro/gluetun/quadlet/gluetun.container [ "$status" -eq 0 ] - [ "$output" = $'gluetun\nqbittorrent' ] + [ "$output" = $'gluetun\nprowlarr\nqbittorrent\nradarr\nsonarr\nlidarr\nreadarr' ] run python3 "$PLANNER" --target frankbook-pro --paths \ frankbook-pro/sonarr/quadlet/sonarr.container [ "$status" -eq 0 ] diff --git a/tests/frankbook_quadlet.bats b/tests/frankbook_quadlet.bats index 67ec1d6..29e094d 100755 --- a/tests/frankbook_quadlet.bats +++ b/tests/frankbook_quadlet.bats @@ -2,9 +2,12 @@ # Seam: the FrankBook-Pro quadlet units (gluetun, qbittorrent, five *arrs). # These units are installed INSIDE the Mac's podman machine VM by the # frankbook-pro handler in deploy-remote.sh. The contract pinned here: -# boot persistence, self-healing, the gluetun netns invariant (qBittorrent -# publishes no port of its own; gluetun publishes both), the verbatim -# plan-locked data-root mounts, and zero literal secrets in tracked files. +# boot persistence, self-healing, the VPN egress invariant (the whole +# *arr fleet + qBittorrent share gluetun's netns and publish no ports of +# their own, so ALL egress exits through the WireGuard tunnel — +# AllowedIPs=0.0.0.0/0 — by construction; gluetun publishes every fleet +# port), the verbatim plan-locked data-root mounts, and zero literal +# secrets in tracked files. load helpers @@ -31,18 +34,22 @@ FRANKBOOK_UNITS=( done } -@test "qbittorrent joins gluetun's netns and publishes no port of its own" { - local unit="$REPO_ROOT/frankbook-pro/qbittorrent/quadlet/qbittorrent.container" - grep -q '^Network=gluetun.container$' "$unit" || return 1 - grep -q '^Wants=gluetun.service$' "$unit" || return 1 - grep -q '^After=gluetun.service$' "$unit" || return 1 - ! grep -q '^PublishPort=' "$unit" || return 1 +@test "every arr and qbittorrent joins gluetun's netns and publishes no port" { + local svc unit + for svc in qbittorrent prowlarr sonarr radarr lidarr readarr; do + unit="$REPO_ROOT/frankbook-pro/$svc/quadlet/$svc.container" + grep -q '^Network=gluetun.container$' "$unit" || return 1 + grep -q '^Wants=gluetun.service$' "$unit" || return 1 + grep -q '^After=gluetun.service$' "$unit" || return 1 + ! grep -q '^PublishPort=' "$unit" || return 1 + done } -@test "gluetun publishes qbittorrent's WebUI and torrent ports and needs NET_ADMIN+tun" { +@test "gluetun publishes the whole fleet's ports and needs NET_ADMIN+tun" { local unit="$REPO_ROOT/frankbook-pro/gluetun/quadlet/gluetun.container" - grep -q '^PublishPort=8080:8080$' "$unit" || return 1 - grep -q '^PublishPort=6881:6881$' "$unit" || return 1 + for port in 8080 6881 9696 8989 7878 8686 8787; do + grep -q "^PublishPort=$port:$port\$" "$unit" || return 1 + done grep -q '^PublishPort=6881:6881/udp$' "$unit" || return 1 grep -q '^AddCapability=NET_ADMIN$' "$unit" || return 1 grep -q '^AddDevice=/dev/net/tun:/dev/net/tun$' "$unit" || return 1 @@ -69,12 +76,7 @@ FRANKBOOK_UNITS=( grep -q '^Volume=/Volumes/INCOMPLETE:/Volumes/INCOMPLETE$' "$unit" || return 1 } -@test "every arr serves its standard port and readarr keeps its pinned image" { - grep -q '^PublishPort=9696:9696$' "$REPO_ROOT/frankbook-pro/prowlarr/quadlet/prowlarr.container" || return 1 - grep -q '^PublishPort=8989:8989$' "$REPO_ROOT/frankbook-pro/sonarr/quadlet/sonarr.container" || return 1 - grep -q '^PublishPort=7878:7878$' "$REPO_ROOT/frankbook-pro/radarr/quadlet/radarr.container" || return 1 - grep -q '^PublishPort=8686:8686$' "$REPO_ROOT/frankbook-pro/lidarr/quadlet/lidarr.container" || return 1 - grep -q '^PublishPort=8787:8787$' "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1 +@test "readarr keeps its pinned image" { grep -q '^Image=lscr.io/linuxserver/readarr:amd64-1.0.980$' \ "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1 }