Files
serverAppConfigs/tests/dankserver_quadlet.bats
Frank cfdd3435bc fix(audiobookshelf): mount the app at the base path its SPA was built for
ROUTER_BASE_PATH=/ served a healthy API and a web UI that could never boot. The index.html in the pinned image is built with routerBasePath:/audiobookshelf, so every script request fell through to the SPA fallback and came back as text/html, which Chrome refuses to execute. healthcheck, the API and the phone app all stayed fine, which is exactly how this hid behind an API-only deploy check.

The deployer now reads the mounted base path out of the unit and hands it to the bootstrapper as ABS_URL, so the API door, the login route and the OIDC redirect subfolder cannot drift from the unit. healthcheck answers at the root under either base path, so the health gate is unchanged.
2026-11-02 19:50:44 -08:00

195 lines
11 KiB
Bash
Executable File

#!/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/<svc>/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.
# The base path is not a free choice either: /app/client/dist/index.html inside the
# pinned image carries routerBasePath:"/audiobookshelf", so a server mounted at / hands
# out a page whose every script request falls through to the SPA fallback and comes back
# text/html — the UI never leaves "Loading…" while the API stays healthy (measured
# 2026-11-02, which is exactly how it hid behind an API-only verification).
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
grep -q '^Environment=PORT=8000$' "$unit" || return 1
grep -q '^Environment=ROUTER_BASE_PATH=/audiobookshelf$' "$unit" || return 1
! grep -qE '^Environment=ABS_' "$unit" || return 1
}