#!/usr/bin/env bats # Seam: the eight cutover-era dankserver quadlet units (romm trio, jellyfin, # calibre-web, copyparty, plex, tautulli) must keep their boot-persistence and v1-generator # contracts. The podman 5.x v1 Quadlet generator silently drops or hard-fails # on several keys; these tests pin the contract that keeps the units loadable # and secret-free in the repo. load helpers @test "all eight cutover units exist under dankserver//quadlet/" { for unit in romm/quadlet/romm romm/quadlet/romm-mariadb romm/quadlet/romm-redis jellyfin/quadlet/jellyfin calibre-web/quadlet/calibre-web copyparty/quadlet/copyparty plex/quadlet/plex tautulli/quadlet/tautulli; do [ -f "$REPO_ROOT/dankserver/$unit.container" ] || return 1 done } @test "every cutover unit is boot-persistent (WantedBy=default.target)" { for unit in romm/quadlet/romm romm/quadlet/romm-mariadb romm/quadlet/romm-redis jellyfin/quadlet/jellyfin calibre-web/quadlet/calibre-web copyparty/quadlet/copyparty plex/quadlet/plex tautulli/quadlet/tautulli; do grep -q '^WantedBy=default.target$' "$REPO_ROOT/dankserver/$unit.container" || return 1 done } @test "copyparty uses Exec= not Command= (v1 generator hard-fails on Command=)" { unit="$REPO_ROOT/dankserver/copyparty/quadlet/copyparty.container" grep -q '^Command=' "$unit" && return 1 grep -q '^Exec=-a ${COPAY_ACCOUNT}:${COPAY_PASSWORD}' "$unit" || return 1 } @test "copyparty and romm consume host-local env files, never in-repo secrets" { grep -q '^EnvironmentFile=/home/dank/Containers/copyparty/env$' \ "$REPO_ROOT/dankserver/copyparty/quadlet/copyparty.container" || return 1 for unit in romm/quadlet/romm romm/quadlet/romm-mariadb; do grep -q '^EnvironmentFile=/home/dank/Containers/romm/env$' \ "$REPO_ROOT/dankserver/$unit.container" || return 1 done # No literal credential may ever appear in a tracked unit file: the only # Exec= arguments must be variable expansions, never a literal account:secret. ! grep -qE '\-a [A-Za-z0-9]+:[A-Za-z0-9]' "$REPO_ROOT/dankserver/copyparty/quadlet/copyparty.container" || return 1 } @test "romm db services keep their service-discovery aliases on the appnet" { grep -q '^Network=dankserver_appnet:alias=mariadb$' \ "$REPO_ROOT/dankserver/romm/quadlet/romm-mariadb.container" || return 1 grep -q '^Network=dankserver_appnet:alias=redis$' \ "$REPO_ROOT/dankserver/romm/quadlet/romm-redis.container" || return 1 } @test "no unit uses the unsupported Device= key (v1 generator needs AddDevice=)" { # A rejected file kills its own unit silently: the deploy would then die # with "Unit jellyfin.service not found" only on the target. Device= was # the first offender (jellyfin GPU passthrough); Command= is guarded # above. Keep this list current with every generator finding. for unit in dankserver/romm/quadlet/romm dankserver/romm/quadlet/romm-mariadb \ dankserver/romm/quadlet/romm-redis dankserver/jellyfin/quadlet/jellyfin \ dankserver/calibre-web/quadlet/calibre-web dankserver/copyparty/quadlet/copyparty \ dankserver/plex/quadlet/plex dankserver/tautulli/quadlet/tautulli; do ! grep -q '^Device=' "$REPO_ROOT/$unit.container" || return 1 done grep -q '^AddDevice=/dev/dri$' "$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container" || return 1 } @test "every host-served port is explicitly published (no silent bridge-only)" { grep -q '^PublishPort=8085:8080$' "$REPO_ROOT/dankserver/romm/quadlet/romm.container" || return 1 grep -q '^PublishPort=8096:8096$' "$REPO_ROOT/dankserver/jellyfin/quadlet/jellyfin.container" || return 1 grep -q '^PublishPort=8083:8083$' "$REPO_ROOT/dankserver/calibre-web/quadlet/calibre-web.container" || return 1 grep -q '^PublishPort=3923:3923$' "$REPO_ROOT/dankserver/copyparty/quadlet/copyparty.container" || return 1 grep -q '^PublishPort=32400:32400$' "$REPO_ROOT/dankserver/plex/quadlet/plex.container" || return 1 grep -q '^PublishPort=8181:8181$' "$REPO_ROOT/dankserver/tautulli/quadlet/tautulli.container" || return 1 } @test "plex runs the fleet identity model (PUID=0/PGID=0, no userns remap)" { # Rootless podman cannot map the real host uid 997 (subuid range is # 100000:65536), so the fleet model applies: container root maps to the # dank user and the deployer chowns /var/lib/plexmediaserver to # 1000:1000 on first cutover; rollback chowns back to 997:984. Pin both # keys and the ABSENCE of any userns key — UsernsMode= is compose syntax # (the v1 generator hard-fails the whole unit on it) and UserNS= would # remap the container away from the fleet identity. unit="$REPO_ROOT/dankserver/plex/quadlet/plex.container" grep -q '^Environment=PUID=0$' "$unit" || return 1 grep -q '^Environment=PGID=0$' "$unit" || return 1 ! grep -qE '^(UserNS|UsernsMode)=' "$unit" || return 1 } @test "calibre-server unit exists and is boot-persistent" { unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" [ -f "$unit" ] || return 1 grep -q '^WantedBy=default.target$' "$unit" || return 1 grep -q '^Restart=always$' "$unit" || return 1 } @test "calibre-server serves the content server auth-gated on the published port" { # The security contract: the content server runs with --enable-auth and # --auth-mode=basic (Digest is the non-SSL default and rejects Basic # clients with 400), on the internal port the unit publishes as 8084. unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" grep -q '^Exec=calibre-server --port 8090 --enable-auth --auth-mode=basic --userdb /userdb.sqlite /books$' "$unit" || return 1 grep -q '^PublishPort=8084:8090$' "$unit" || return 1 } @test "calibre-server consumes host-local credential stores, never in-repo secrets" { unit="$REPO_ROOT/dankserver/calibre-server/quadlet/calibre-server.container" grep -q '^EnvironmentFile=/home/dank/Containers/calibre-server/env$' "$unit" || return 1 grep -q '^Volume=/home/dank/Containers/calibre-server/userdb.sqlite:/userdb.sqlite$' "$unit" || return 1 # The userdb's pw column is plaintext: no literal password may appear in # the tracked unit, and the desktop basic-auth credential comes from the # env file, not an Environment= line. ! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1 } @test "a quadlet that asks for an I/O share ships the host permission that makes it real" { # IOWeight= is silently inert unless the system manager both enables io for # user.slice and delegates it to the user manager: systemd drops a resource # line it cannot honour without a word and the container still starts healthy. # So an in-repo weight is only honest together with both drop-ins AND with a # deployer that places them. Measured 2026-10-31; verification chain in # Architecture/Quadlet Lifecycle 4b. if grep -lq '^IOWeight=' "$REPO_ROOT"/dankserver/*/quadlet/*.container; then asked=1 else skip "no dankserver quadlet asks for an I/O share" fi grep -q '^IOAccounting=yes$' \ "$REPO_ROOT/dankserver/host/systemd/user.slice.d/io-accounting.conf" || return 1 grep -q '^DelegateControllers=.* io' \ "$REPO_ROOT/dankserver/host/systemd/user@1000.service.d/io-delegate.conf" || return 1 for f in user.slice.d/io-accounting.conf 'user@1000.service.d/io-delegate.conf'; do grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1 done } @test "audiobookshelf unit exists, is boot-persistent and self-healing" { unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" [ -f "$unit" ] || return 1 grep -q '^WantedBy=default.target$' "$unit" || return 1 grep -q '^Restart=always$' "$unit" || return 1 grep -q '^RestartSec=' "$unit" || return 1 } @test "audiobookshelf keeps every byte it writes on the SSD" { # The point of the unit: its library database, accounts, progress and cached # covers are all server state, and server state belongs on the NVMe. A bind # of /mnt/entropy that is not :ro would silently put writes on the spindle. unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" grep -q '^Volume=/home/dank/Containers/audiobookshelf/config:/config$' "$unit" || return 1 grep -q '^Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata$' "$unit" || return 1 grep -q '^Volume=/mnt/entropy/Audiobooks:/audiobooks:ro$' "$unit" || return 1 # The overlay view is reserved for the two media servers that write sidecars # (tests/media_overlay.bats pins that set); a reader must not join it. ! grep -q 'media-meta/view' "$unit" || return 1 } @test "audiobookshelf refuses to start against an unmounted array" { # Without the gate podman invents an empty bind source and the first scan # records an empty library as truth. unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" grep -q '^ExecStartPre=.*/wait-for-mount.sh /mnt/entropy$' "$unit" || return 1 } @test "audiobookshelf health check names a binary its image actually ships" { # The image is Alpine: wget yes, curl no. A curl HealthCmd reads the # container as unhealthy while it is serving perfectly. unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" grep -q '^HealthCmd=wget ' "$unit" || return 1 ! grep -q '^HealthCmd=curl' "$unit" || return 1 grep -q '^PublishPort=8000:8000$' "$unit" || return 1 } @test "the audiobookshelf deployer creates both state roots before it starts" { body="$(sed -n '/^deploy_audiobookshelf()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")" mkdir_line="$(printf '%s\n' "$body" | grep -n 'mkdir -p' | head -1 | cut -d: -f1)" start_line="$(printf '%s\n' "$body" | grep -n 'systemctl --user restart' | head -1 | cut -d: -f1)" [ -n "$mkdir_line" ] && [ -n "$start_line" ] || return 1 [ "$mkdir_line" -lt "$start_line" ] || return 1 printf '%s\n' "$body" | grep -q 'audiobookshelf/config' || return 1 printf '%s\n' "$body" | grep -q 'audiobookshelf/metadata' || return 1 printf '%s\n' "$body" | grep -q 'wait_for_health audiobookshelf' || return 1 } @test "audiobookshelf sets its port and base path with the names the image reads" { # Measured 2026-11-02 on the first deploy: ABS_PORT / ABS_CONFIG_DIR are the npm # package's names and this image ignores them, so the server came up on :80 # under base path /audiobookshelf and the deploy gate timed out beside a unit # that was active (running). PORT and ROUTER_BASE_PATH are what the image's own # ENV block and the server source actually use. unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" grep -q '^Environment=PORT=8000$' "$unit" || return 1 grep -q '^Environment=ROUTER_BASE_PATH=/$' "$unit" || return 1 ! grep -qE '^Environment=ABS_' "$unit" || return 1 }