From 949f68e2b5d49c5652df55037691bc32f432db17 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 1 Sep 2026 14:10:23 -0700 Subject: [PATCH] frankbook-pro: *arr stack + qbittorrent quadlets inside podman machine Seven units installed INSIDE the Mac VM (/home/core/.config/containers/ systemd) by a new deploy_frankbook_stack handler: gluetun (ProtonVPN WireGuard, env-file credentials, publishes qbit's 8080/6881), qbittorrent (Network=gluetun.container, verbatim /Volumes data roots, first-boot config seed with fresh machine-local WebUI password and the Finished Torrents category), and prowlarr/sonarr/radarr/lidarr/readarr (grab-only, config migrated from dankserver at provisioning, readarr pinned to amd64-1.0.980). deploy.sh routes the new target through the ssh-config alias with REMOTE_SHELL=/usr/local/bin/bash (macOS bash 3.2 lacks associative arrays); the planner learns FRANKBOOK_SERVICES and the gluetun->qbittorrent netns pairing; new seam test pins the contract. --- .../gluetun/quadlet/gluetun.container | 43 ++++++ frankbook-pro/lidarr/quadlet/lidarr.container | 26 ++++ .../prowlarr/quadlet/prowlarr.container | 33 +++++ .../qbittorrent/quadlet/qbittorrent.container | 43 ++++++ frankbook-pro/radarr/quadlet/radarr.container | 26 ++++ .../readarr/quadlet/readarr.container | 30 ++++ frankbook-pro/sonarr/quadlet/sonarr.container | 27 ++++ scripts/deploy-plan.py | 29 +++- scripts/deploy-remote.sh | 134 +++++++++++++++++- scripts/deploy.sh | 40 ++++-- scripts/test-changed.sh | 8 ++ tests/deployment_scoping.bats | 11 ++ tests/frankbook_quadlet.bats | 80 +++++++++++ 13 files changed, 516 insertions(+), 14 deletions(-) create mode 100644 frankbook-pro/gluetun/quadlet/gluetun.container create mode 100644 frankbook-pro/lidarr/quadlet/lidarr.container create mode 100644 frankbook-pro/prowlarr/quadlet/prowlarr.container create mode 100644 frankbook-pro/qbittorrent/quadlet/qbittorrent.container create mode 100644 frankbook-pro/radarr/quadlet/radarr.container create mode 100644 frankbook-pro/readarr/quadlet/readarr.container create mode 100644 frankbook-pro/sonarr/quadlet/sonarr.container create mode 100755 tests/frankbook_quadlet.bats diff --git a/frankbook-pro/gluetun/quadlet/gluetun.container b/frankbook-pro/gluetun/quadlet/gluetun.container new file mode 100644 index 0000000..f1bc228 --- /dev/null +++ b/frankbook-pro/gluetun/quadlet/gluetun.container @@ -0,0 +1,43 @@ +# Quadlet unit: Gluetun VPN tunnel in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/gluetun.container +# (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 +# /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. +[Unit] +Description=Gluetun VPN tunnel (FrankBook-Pro podman machine) + +[Container] +ContainerName=gluetun +Image=docker.io/qmcgaw/gluetun:latest +AddCapability=NET_ADMIN +AddDevice=/dev/net/tun:/dev/net/tun +Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} +Environment=VPN_TYPE=${VPN_TYPE} +Environment=WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} +Environment=WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} +Environment=WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY} +Environment=WIREGUARD_ENDPOINT_IP=${WIREGUARD_ENDPOINT_IP} +Environment=WIREGUARD_ENDPOINT_PORT=${WIREGUARD_ENDPOINT_PORT} +Environment=WIREGUARD_ALLOWED_IPS=${WIREGUARD_ALLOWED_IPS} +Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL} +PublishPort=8080:8080 +PublishPort=6881:6881 +PublishPort=6881:6881/udp + +[Service] +EnvironmentFile=/home/core/Containers/gluetun/env +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/lidarr/quadlet/lidarr.container b/frankbook-pro/lidarr/quadlet/lidarr.container new file mode 100644 index 0000000..6e1ba2a --- /dev/null +++ b/frankbook-pro/lidarr/quadlet/lidarr.container @@ -0,0 +1,26 @@ +# Quadlet unit: Lidarr in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/lidarr.container +# +# Grab-only: no media binds; config migrated from dankserver's retired +# bare container. +[Unit] +Description=Lidarr (FrankBook-Pro) + +[Container] +ContainerName=lidarr +Image=lscr.io/linuxserver/lidarr:latest +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/lidarr-data:/config +PublishPort=8686:8686 + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/prowlarr/quadlet/prowlarr.container b/frankbook-pro/prowlarr/quadlet/prowlarr.container new file mode 100644 index 0000000..5cff9c5 --- /dev/null +++ b/frankbook-pro/prowlarr/quadlet/prowlarr.container @@ -0,0 +1,33 @@ +# Quadlet unit: Prowlarr in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/prowlarr.container +# +# 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 +# library, which lives on dankserver's entropy disk). +# +# 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. +[Unit] +Description=Prowlarr (FrankBook-Pro) + +[Container] +ContainerName=prowlarr +Image=lscr.io/linuxserver/prowlarr:latest +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/prowlarr-data:/config +PublishPort=9696:9696 + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/qbittorrent/quadlet/qbittorrent.container b/frankbook-pro/qbittorrent/quadlet/qbittorrent.container new file mode 100644 index 0000000..9f1a9e0 --- /dev/null +++ b/frankbook-pro/qbittorrent/quadlet/qbittorrent.container @@ -0,0 +1,43 @@ +# Quadlet unit: qBittorrent in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/qbittorrent.container +# +# Joins gluetun's network namespace (Network=gluetun.container) so all +# BitTorrent egress exits the WireGuard tunnel. It publishes NO port of its +# own — WebUI 8080 and torrent port 6881 publish on the gluetun unit. +# +# Data layout (plan-locked, verbatim paths): +# /config <- /Volumes/COMPLETE/qbittorrent-data (durable config) +# /Volumes/COMPLETE + /Volumes/INCOMPLETE same-path virtiofs passthroughs — +# the seeded qBittorrent.conf references those literal paths: +# Session\DefaultSavePath=/Volumes/COMPLETE +# Session\TempPath=/Volumes/INCOMPLETE/incomplete (in-progress disk) +# category "Finished Torrents" -> /Volumes/COMPLETE/Finished Torrents +# (the human review dir; nothing automated ever writes to entropy). +# +# WebUI password: generated fresh at provisioning by the deploy handler, +# stored machine-local only (conf + /home/core/Containers/qbittorrent/env). +[Unit] +Description=qBittorrent behind gluetun (FrankBook-Pro) +Wants=gluetun.service +After=gluetun.service + +[Container] +ContainerName=qbittorrent +Network=gluetun.container +Image=lscr.io/linuxserver/qbittorrent:latest +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/qbittorrent-data:/config +Volume=/Volumes/COMPLETE:/Volumes/COMPLETE +Volume=/Volumes/INCOMPLETE:/Volumes/INCOMPLETE + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/radarr/quadlet/radarr.container b/frankbook-pro/radarr/quadlet/radarr.container new file mode 100644 index 0000000..1ad3410 --- /dev/null +++ b/frankbook-pro/radarr/quadlet/radarr.container @@ -0,0 +1,26 @@ +# Quadlet unit: Radarr in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/radarr.container +# +# Grab-only: no media binds; config migrated from dankserver's retired +# bare container. +[Unit] +Description=Radarr (FrankBook-Pro) + +[Container] +ContainerName=radarr +Image=lscr.io/linuxserver/radarr:latest +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/radarr-data:/config +PublishPort=7878:7878 + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/readarr/quadlet/readarr.container b/frankbook-pro/readarr/quadlet/readarr.container new file mode 100644 index 0000000..d6351c5 --- /dev/null +++ b/frankbook-pro/readarr/quadlet/readarr.container @@ -0,0 +1,30 @@ +# Quadlet unit: Readarr in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/readarr.container +# +# Grab-only: no media binds; config migrated from dankserver's retired +# bare container. +# +# Image pinned to amd64-1.0.980 (per project image-pin policy — same tag the +# dankserver compose ran): `latest` historically shipped an amd64-only build +# of a broken 1.x nightly for Readarr; the pin avoids surprises. +[Unit] +Description=Readarr (FrankBook-Pro) + +[Container] +ContainerName=readarr +Image=lscr.io/linuxserver/readarr:amd64-1.0.980 +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/readarr-data:/config +PublishPort=8787:8787 + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/frankbook-pro/sonarr/quadlet/sonarr.container b/frankbook-pro/sonarr/quadlet/sonarr.container new file mode 100644 index 0000000..4358df1 --- /dev/null +++ b/frankbook-pro/sonarr/quadlet/sonarr.container @@ -0,0 +1,27 @@ +# Quadlet unit: Sonarr in FrankBook-Pro's podman machine. +# +# Install path is INSIDE the machine VM: +# /home/core/.config/containers/systemd/sonarr.container +# +# 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) + +[Container] +ContainerName=sonarr +Image=lscr.io/linuxserver/sonarr:latest +Environment=PUID=0 +Environment=PGID=0 +Environment=TZ=America/Los_Angeles +Environment=UMASK_SET=022 +Volume=/Volumes/COMPLETE/sonarr-data:/config +PublishPort=8989:8989 + +[Service] +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 8761f32..ce5341b 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -104,6 +104,20 @@ DANKSERVER_SERVICES = { "frank-default-small", } +# FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on +# macOS. Service names overlap the Linux hosts (gluetun, qbittorrent, ...); +# the per-target sets and the frankbook-pro intercept in deploy-remote.sh +# keep dispatch unambiguous. +FRANKBOOK_SERVICES = { + "gluetun", + "qbittorrent", + "prowlarr", + "radarr", + "sonarr", + "lidarr", + "readarr", +} + DANKCLAW_COMPOSE_SERVICES = { "comfyui", "hermes", @@ -147,6 +161,8 @@ def target_services(target: str) -> set[str]: return DANKCLAW_SERVICES if target == "dankserver": return DANKSERVER_SERVICES + if target == "frankbook-pro": + return FRANKBOOK_SERVICES raise ValueError(f"unknown target: {target}") @@ -313,6 +329,17 @@ def services_for_path(path: str, target: str) -> set[str]: _add(services, *quadlet_services.get(parts[1], set())) return services + if parts[0] == "frankbook-pro": + 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") + elif name in FRANKBOOK_SERVICES: + _add(services, name) + return services + if parts[0] == "scripts": name = p.name if name in {"frank-default-large.env", "frank-default-large.service", "serve-frank-large.sh"}: @@ -362,7 +389,7 @@ def plan(paths: list[str], target: str, repo: str | None = None, def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--target", choices=("dankclaw", "dankserver"), required=True) + parser.add_argument("--target", choices=("dankclaw", "dankserver", "frankbook-pro"), required=True) parser.add_argument("--paths", nargs="+") parser.add_argument("--repo", help="repository path for revision-aware planning") parser.add_argument("--base", help="base Git revision") diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index d3cef9f..fbbe325 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -18,7 +18,7 @@ SERVICES=() usage() { cat <<'EOF' -Usage: deploy-remote.sh [--pending] [service]... +Usage: deploy-remote.sh [--pending] [service]... The service list is explicit. An empty list is a successful no-op. EOF @@ -57,7 +57,7 @@ run_privileged() { fi } -if [[ "$TARGET" != dankclaw && "$TARGET" != dankserver ]]; then +if [[ "$TARGET" != dankclaw && "$TARGET" != dankserver && "$TARGET" != frankbook-pro ]]; then usage >&2 exit 2 fi @@ -96,7 +96,10 @@ service_is_allowed() { dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|\ dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\ dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\ - dankserver:frank-default-small) + dankserver:frank-default-small|\ + frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\ + frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\ + frankbook-pro:readarr) return 0 ;; *) @@ -814,8 +817,133 @@ wait_for_health() { return 1 } +# --------------------------------------------------------------------------- +# FrankBook-Pro (macOS): quadlet units live INSIDE the podman machine VM +# (/home/core/.config/containers/systemd), not in the macOS user session. +# This handler pipes unit files and seed logic into the VM over +# `podman machine ssh`; it never touches systemctl on the Mac host and never +# uses run_privileged (the Mac account is not passwordless sudo). Unit files +# are re-piped on every call (idempotent, cheap) before the selected unit is +# restarted; first boot also seeds the qBittorrent config once. +FRANKBOOK_SVCS=(gluetun qbittorrent prowlarr sonarr radarr lidarr readarr) +deploy_frankbook_stack() { + local service="$1" + export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH" + if ! podman machine inspect --format '{{.State}}' 2>/dev/null | grep -qx running; then + echo "FAIL: podman machine is not running on frankbook-pro" >&2 + return 1 + fi + local vm_dir="/home/core/.config/containers/systemd" svc + podman machine ssh "mkdir -p '$vm_dir' /home/core/Containers/gluetun /home/core/Containers/qbittorrent" + for svc in "${FRANKBOOK_SVCS[@]}"; do + podman machine ssh "cat > '$vm_dir/$svc.container'" \ + < "frankbook-pro/$svc/quadlet/$svc.container" + done + local tmp + tmp="$(mktemp /tmp/frankbook-vm.XXXXXX)" + cat > "$tmp" <<'VM_SETUP' +set -euo pipefail +SVC="${1:?service required}" +# qBittorrent first-boot seed: conf + categories + machine-local password. +if [ ! -e /home/core/Containers/qbittorrent/qbit-seeded ]; then + mkdir -p /Volumes/COMPLETE/qbittorrent-data/qBittorrent + QBIT_PW="$(openssl rand -base64 18 | tr -d '/+=')" + umask 077 + printf 'QBIT_WEBUI_PASSWORD=%s\n' "$QBIT_PW" > /home/core/Containers/qbittorrent/env + umask 022 + { + cat <<'QCONF' +[AutoRun] + +[BitTorrent] +Session\DefaultSavePath=/Volumes/COMPLETE +Session\Port=6881 +Session\QueueingSystemEnabled=true +Session\TempPath=/Volumes/INCOMPLETE/incomplete +Session\TempPathEnabled=true + +[LegalNotice] + +[Meta] +MigrationVersion=8 + +[Network] +Cookies\Enabled=true + +[Preferences] +WebUI\Address=* +WebUI\Username=admin +QCONF + printf 'WebUI\\Password=%s\n' "$QBIT_PW" + } > /Volumes/COMPLETE/qbittorrent-data/qBittorrent/qBittorrent.conf + cat > /Volumes/COMPLETE/qbittorrent-data/qBittorrent/categories.json <<'QCAT' +{ + "Finished Torrents": { + "save_path": "/Volumes/COMPLETE/Finished Torrents" + }, + "lidarr": {"save_path": ""}, + "radarr": {"save_path": ""}, + "tv-sonarr": {"save_path": ""} +} +QCAT + touch /home/core/Containers/qbittorrent/qbit-seeded + echo " seeded qBittorrent config (WebUI password: /home/core/Containers/qbittorrent/env inside the VM)" +fi +if [ "$SVC" = gluetun ] && [ ! -s /home/core/Containers/gluetun/env ]; then + echo "SKIP: gluetun has no credentials yet." + echo " Write the ProtonVPN WireGuard keys into /home/core/Containers/gluetun/env inside the VM" + echo " (podman machine ssh), then re-run: ./scripts/deploy.sh frankbook-pro --service gluetun" + exit 0 +fi +if [ "$SVC" = qbittorrent ] && [ ! -s /home/core/Containers/gluetun/env ]; then + echo "SKIP: qbittorrent waits for gluetun credentials." + exit 0 +fi +systemctl --user daemon-reload +unit="$(systemctl --user list-units --all --no-legend "${SVC}.service" "container-${SVC}.service" 2>/dev/null | head -n1 | awk '{print $1}')" +[ -n "$unit" ] || { echo "FAIL: no generated systemd unit for $SVC" >&2; exit 1; } +systemctl --user restart "$unit" +sleep 3 +state="$(systemctl --user is-active "$unit" || true)" +if [ "$state" != active ] && [ "$state" != activating ]; then + echo "FAIL: $unit is $state" >&2 + systemctl --user status "$unit" --no-pager -l || true + exit 1 +fi +VM_SETUP + podman machine ssh bash -s "$service" < "$tmp" + rm -f "$tmp" + local port path + case "$service" in + qbittorrent) port=8080; path=/ ;; + prowlarr) port=9696; path=/ping ;; + sonarr) port=8989; path=/ping ;; + radarr) port=7878; path=/ping ;; + lidarr) port=8686; path=/ping ;; + readarr) port=8787; path=/ping ;; + *) return 0 ;; # gluetun publishes only qBittorrent's ports + esac + printf ' waiting for http://127.0.0.1:%s%s on the Mac...\n' "$port" "$path" + local ok=0 i + for i in $(seq 1 60); do + if curl -fsS -m 4 "http://127.0.0.1:${port}${path}" >/dev/null 2>&1; then + ok=1 + break + fi + sleep 5 + done + [[ "$ok" -eq 1 ]] || { echo "FAIL: $service did not answer on 127.0.0.1:$port" >&2; return 1; } + echo " $service answers on 127.0.0.1:$port" +} + deploy_service() { local service="$1" + if [[ "$TARGET" == "frankbook-pro" ]]; then + # All frankbook-pro services route through the Mac handler: the name + # case below addresses the Linux hosts, and service names overlap. + deploy_frankbook_stack "$service" + return + fi case "$service" in caddy) deploy_caddy ;; homepage) deploy_homepage ;; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index fe52836..b6ac733 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,7 +7,7 @@ # restarted only when its own files (or the shared chat template) are selected. # # Usage: -# ./scripts/deploy.sh [--service NAME]... [--dry-run] +# ./scripts/deploy.sh [--service NAME]... [--dry-run] # # Without --service, the planner selects services changed since origin/main and # the remote runner also applies pending service changes from its state store. @@ -20,10 +20,11 @@ REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" PLANNER="$SCRIPT_DIR/deploy-plan.py" REMOTE_USER="dank" REMOTE_DIR="/home/dank/serverAppConfigs" +REMOTE_SHELL="bash" usage() { cat <<'EOF' -Usage: deploy.sh [options] +Usage: deploy.sh [options] Options: --service NAME deploy only NAME (repeatable; bypasses automatic planning) @@ -50,8 +51,21 @@ fi shift case "$TARGET" in - dankclaw) REMOTE_HOST="dankclaw" ;; - dankserver) REMOTE_HOST="dankserver" ;; + dankclaw) + REMOTE_HOST="dankclaw" + ;; + dankserver) + REMOTE_HOST="dankserver" + ;; + frankbook-pro) + REMOTE_HOST="frankbook-pro" + REMOTE_USER="dank" + REMOTE_DIR="/Users/dank/Projects/serverAppConfigs" + # macOS ships bash 3.2; the runner needs bash >= 4 (associative + # arrays). /usr/local/bin/bash is the Intel-homebrew interpreter, + # provisioned with the Mac checkout. + REMOTE_SHELL="/usr/local/bin/bash" + ;; *) echo "Unknown target: $TARGET" >&2 usage >&2 @@ -126,7 +140,10 @@ valid_service() { dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\ dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\ dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\ - dankserver:frank-default-small) + dankserver:frank-default-small|\ + frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\ + frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\ + frankbook-pro:readarr) return 0 ;; *) @@ -174,7 +191,7 @@ else fi REMOTE="${REMOTE_USER}@${REMOTE_HOST}" -REMOTE_ARGS=("$TARGET" "$HEAD_REVISION") +REMOTE_ARGS=("$TARGET" "$HEAD_REVISION" "$REMOTE_DIR" "$REMOTE_SHELL") if [[ "${#EXPLICIT_SERVICES[@]}" -eq 0 ]]; then REMOTE_ARGS+=(--pending) fi @@ -196,12 +213,15 @@ echo "[2/2] Pulling and deploying on $REMOTE_HOST..." # The target runner computes any pending revisions from its own state after # the pull. This keeps a target that missed an earlier service deployment # recoverable without restarting services that are already current. -ssh "$REMOTE" bash -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF' +ssh "$REMOTE" "$REMOTE_SHELL" -s -- "${REMOTE_ARGS[@]}" <<'REMOTE_EOF' set -euo pipefail TARGET="$1" EXPECTED_REVISION="$2" -shift 2 -cd "/home/dank/serverAppConfigs" +REPO_DIR="$3" +REMOTE_SHELL="$4" +shift 4 +export REMOTE_DIR="$REPO_DIR" +cd "$REPO_DIR" OLD_REVISION="$(git rev-parse HEAD)" echo " pulling origin/main..." git pull --ff-only origin main @@ -210,7 +230,7 @@ if [[ "$NEW_REVISION" != "$EXPECTED_REVISION" ]]; then echo "FAIL: target checkout is $NEW_REVISION, expected $EXPECTED_REVISION" >&2 exit 1 fi -exec ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \ +exec "$REMOTE_SHELL" ./scripts/deploy-remote.sh "$TARGET" "$NEW_REVISION" "$@" \ --from-revision "$OLD_REVISION" REMOTE_EOF diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index 1e4ab39..99e3968 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -213,6 +213,14 @@ for path in "${CHANGED[@]}"; do add_test tests/deployment_flow.bats add_test tests/deployment_scoping.bats ;; + # FrankBook-Pro units live inside the podman machine VM; the seam + # pins boot persistence, the gluetun netns invariant, and the + # plan-locked volume layout. Deployer changes route here too. + frankbook-pro/*) + add_test tests/frankbook_quadlet.bats + add_test tests/deployment_flow.bats + add_test tests/deployment_scoping.bats + ;; report/*) # Frozen audit reports are point-in-time artifacts; not testable. ;; diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats index 4a64920..472063d 100644 --- a/tests/deployment_scoping.bats +++ b/tests/deployment_scoping.bats @@ -111,6 +111,17 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" [ "$output" = $'gitea-postgres\ngitea-server' ] } +@test "frankbook-pro gluetun changes carry qbittorrent; arr changes stay scoped" { + run python3 "$PLANNER" --target frankbook-pro --paths \ + frankbook-pro/gluetun/quadlet/gluetun.container + [ "$status" -eq 0 ] + [ "$output" = $'gluetun\nqbittorrent' ] + run python3 "$PLANNER" --target frankbook-pro --paths \ + frankbook-pro/sonarr/quadlet/sonarr.container + [ "$status" -eq 0 ] + [ "$output" = "sonarr" ] +} + @test "remote Caddy deployment no longer blocks on the retired Gitea proxy" { ! grep -q 'refusing to replace it with caddy' "$REMOTE_DEPLOY" ! grep -q 'podman container exists gitea-caddy &&' "$REMOTE_DEPLOY" diff --git a/tests/frankbook_quadlet.bats b/tests/frankbook_quadlet.bats new file mode 100755 index 0000000..67ec1d6 --- /dev/null +++ b/tests/frankbook_quadlet.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats +# 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. + +load helpers + +FRANKBOOK_UNITS=( + gluetun/quadlet/gluetun + qbittorrent/quadlet/qbittorrent + prowlarr/quadlet/prowlarr + sonarr/quadlet/sonarr + radarr/quadlet/radarr + lidarr/quadlet/lidarr + readarr/quadlet/readarr +) + +@test "all seven frankbook-pro units exist under frankbook-pro//quadlet/" { + for unit in "${FRANKBOOK_UNITS[@]}"; do + [ -f "$REPO_ROOT/frankbook-pro/$unit.container" ] || return 1 + done +} + +@test "every frankbook-pro unit is boot-persistent and self-healing" { + for unit in "${FRANKBOOK_UNITS[@]}"; do + grep -q '^WantedBy=default.target$' "$REPO_ROOT/frankbook-pro/$unit.container" || return 1 + grep -q '^Restart=always$' "$REPO_ROOT/frankbook-pro/$unit.container" || return 1 + 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 "gluetun publishes qbittorrent's WebUI and torrent 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 + 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 +} + +@test "gluetun credentials come from a machine-local env file, never literals" { + local unit="$REPO_ROOT/frankbook-pro/gluetun/quadlet/gluetun.container" + grep -q '^EnvironmentFile=/home/core/Containers/gluetun/env$' "$unit" || return 1 + # Only ${VAR} expansions may carry the WireGuard material. + if grep -qE '^Environment=WIREGUARD_PRIVATE_KEY=[^$]' "$unit"; then + return 1 + fi +} + +@test "data roots follow the plan-locked virtiofs layout" { + for svc in qbittorrent prowlarr sonarr radarr lidarr readarr; do + grep -q "^Volume=/Volumes/COMPLETE/$svc-data:/config$" \ + "$REPO_ROOT/frankbook-pro/$svc/quadlet/$svc.container" || return 1 + done + # qBittorrent additionally mounts both external disks verbatim: the + # seeded conf references the literal /Volumes paths. + local unit="$REPO_ROOT/frankbook-pro/qbittorrent/quadlet/qbittorrent.container" + grep -q '^Volume=/Volumes/COMPLETE:/Volumes/COMPLETE$' "$unit" || return 1 + 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 + grep -q '^Image=lscr.io/linuxserver/readarr:amd64-1.0.980$' \ + "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1 +}