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.
This commit is contained in:
Frank
2026-09-01 14:10:23 -07:00
parent 4d5bea42e0
commit 949f68e2b5
13 changed files with 516 additions and 14 deletions
+28 -1
View File
@@ -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")
+131 -3
View File
@@ -18,7 +18,7 @@ SERVICES=()
usage() {
cat <<'EOF'
Usage: deploy-remote.sh <dankclaw|dankserver> <git-revision> [--pending] [service]...
Usage: deploy-remote.sh <dankclaw|dankserver|frankbook-pro> <git-revision> [--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 ;;
+30 -10
View File
@@ -7,7 +7,7 @@
# restarted only when its own files (or the shared chat template) are selected.
#
# Usage:
# ./scripts/deploy.sh <dankclaw|dankserver> [--service NAME]... [--dry-run]
# ./scripts/deploy.sh <dankclaw|dankserver|frankbook-pro> [--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 <dankclaw|dankserver> [options]
Usage: deploy.sh <dankclaw|dankserver|frankbook-pro> [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
+8
View File
@@ -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.
;;