Dispatcharr/docker/tests/test-puid-pgid.sh
None 52ed0fc15a fix: run PostgreSQL as PUID/PGID user to resolve ownership conflicts (#1078)
PostgreSQL now runs as the PUID/PGID user ($POSTGRES_USER) instead of the internal postgres system user (UID 102). This fixes container startup failures when PUID/PGID is set, caused by chown permission errors on restricted filesystems (NFS root_squash, CIFS) and UID collisions with the postgres system user.

Changes:
- Run all PostgreSQL operations (initdb, pg_ctl, psql) as $POSTGRES_USER
- Auto-detect PUID/PGID from existing data owner when not explicitly set
- Validate PUID/PGID (reject zero, non-numeric values) before startup
- Migrate existing data ownership with sentinel-based skip optimization
- Use trust auth for local Unix sockets, md5 for network connections
- Add promote_app_role() and ensure_app_database() as idempotent startup guarantees that handle fresh installs, upgrades, and PUID changes
- Preserve postgres role as superuser for rollback compatibility
- Centralize /data/db ownership in 02-postgres.sh (sentinel-aware)
- Add integration test suite (20 scenarios) covering fresh installs, upgrades, restarts, PUID changes, UID collisions, bind mounts, modular mode, PG major upgrades, and end-to-end web UI verification
2026-03-11 23:36:32 -05:00

1517 lines
54 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
#
# Integration test suite for PUID/PGID Docker init changes.
# Validates runtime behavior across fresh installs, upgrades, restarts,
# storage configurations, and deployment modes.
#
# Prerequisites:
# - Docker Desktop (or Docker Engine) running
# - Internet access (pulls postgres:16, postgres:17, redis:latest,
# and the Dispatcharr release image for upgrade tests)
# - ~10-15 minutes for a full run (PG upgrade scenarios dominate)
#
# Usage:
# cd <repo_root>
# bash docker/tests/test-puid-pgid.sh [--skip-build] [--keep-on-fail] [scenario_name]
#
# Options:
# --skip-build Skip Docker image build (use existing dispatcharr:puid-test image)
# --keep-on-fail Don't clean up containers/volumes on failure (for debugging)
# scenario_name Run only the named scenario (e.g., "fresh_default")
#
# Examples:
# bash docker/tests/test-puid-pgid.sh # Full run (build + all 20 scenarios)
# bash docker/tests/test-puid-pgid.sh fresh_default # Run one scenario
# bash docker/tests/test-puid-pgid.sh --skip-build # Reuse existing image
# bash docker/tests/test-puid-pgid.sh --keep-on-fail # Keep resources for debugging
#
# Scenarios:
# fresh_default Fresh AIO install, default PUID/PGID (1000:1000)
# fresh_custom_puid Fresh AIO install, PUID=1500 PGID=1500
# upgrade_explicit_puid Upgrade from old UID 102 data with explicit PUID=1000
# upgrade_auto_adapt Upgrade from old UID 102 data, no PUID set (auto-detect)
# restart_idempotent Container restart on existing data (no unnecessary chown)
# puid_change Change PUID between restarts (1000 -> 2000)
# uid_collision_102 PUID=102 (collides with postgres system user)
# puid_zero PUID=0 rejected (PostgreSQL can't run as root)
# puid_non_numeric PUID=abc rejected (must be positive integer)
# bind_mount Fresh install on bind mount (local filesystem)
# bind_mount_upgrade Upgrade from UID 102 on bind mount
# bind_mount_auto_adapt Auto-adapt PUID on bind mount (no migration)
# modular_mode External PostgreSQL + Redis (skip internal PG setup)
# custom_postgres_user Custom POSTGRES_USER=myapp
# custom_port Custom POSTGRES_PORT=5433
# tmpfs_volume Ephemeral tmpfs storage
# pg_major_upgrade PostgreSQL 16 -> 17 major version upgrade + ownership migration
# pg_upgrade_post_puid PG 16 -> 17 upgrade on post-PUID data (install user = dispatch)
# e2e_web_ui Full HTTP stack verification (nginx -> uwsgi -> Django)
# readonly_rootfs Read-only root filesystem (security hardened)
#
# Exit codes:
# 0 All tests passed
# 1 One or more tests failed (or build failed)
set -uo pipefail
# Prevent Git Bash (MINGW) from converting Unix paths like /data/db to
# C:/Program Files/Git/data/db when passing arguments to docker exec.
export MSYS_NO_PATHCONV=1
###############################################################################
# Configuration
###############################################################################
IMAGE_NAME="dispatcharr:puid-test"
RELEASE_IMAGE="ghcr.io/dispatcharr/dispatcharr:latest"
BASE_IMAGE="ghcr.io/dispatcharr/dispatcharr:base"
TEST_PREFIX="puid_test"
STARTUP_TIMEOUT=180 # seconds to wait for container startup
SKIP_BUILD=false
KEEP_ON_FAIL=false
SINGLE_SCENARIO=""
USE_RELEASE_IMAGE=false
PASS=0
FAIL=0
SKIP=0
ERRORS=()
# Colors (disabled if not a terminal)
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
###############################################################################
# Parse arguments
###############################################################################
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--keep-on-fail) KEEP_ON_FAIL=true ;;
-*) echo "Unknown option: $arg"; exit 1 ;;
*) SINGLE_SCENARIO="$arg" ;;
esac
done
###############################################################################
# Helpers
###############################################################################
log_pass() { echo -e " ${GREEN}$1${NC}"; ((PASS++)); }
log_fail() { echo -e " ${RED}$1${NC}"; ((FAIL++)); ERRORS+=("[$CURRENT_SCENARIO] $1"); }
log_skip() { echo -e " ${YELLOW}⏭️ $1${NC}"; ((SKIP++)); }
log_info() { echo -e " ${CYAN} $1${NC}"; }
section() { echo -e "\n${BOLD}━━━ $1 ━━━${NC}"; SCENARIO_FAIL_BEFORE=$FAIL; }
CURRENT_SCENARIO=""
CLEANUP_ITEMS=()
# Track resources for cleanup
track_container() { CLEANUP_ITEMS+=("container:$1"); }
track_volume() { CLEANUP_ITEMS+=("volume:$1"); }
track_network() { CLEANUP_ITEMS+=("network:$1"); }
# Create a fresh volume, removing any stale one from a previous run
fresh_volume() {
local vol="$1"
docker rm -f $(docker ps -aq --filter "volume=${vol}") 2>/dev/null || true
docker volume rm "$vol" 2>/dev/null || true
docker volume create "$vol" >/dev/null
track_volume "$vol"
}
cleanup_scenario() {
if [ "$KEEP_ON_FAIL" = true ] && [ ${#ERRORS[@]} -gt 0 ]; then
log_info "Keeping resources for debugging (--keep-on-fail)"
CLEANUP_ITEMS=()
return
fi
for item in "${CLEANUP_ITEMS[@]}"; do
local type="${item%%:*}"
local name="${item#*:}"
case "$type" in
container) docker stop "$name" 2>/dev/null; docker rm -f "$name" 2>/dev/null ;;
volume) docker volume rm "$name" 2>/dev/null ;;
network) docker network rm "$name" 2>/dev/null ;;
esac
done
CLEANUP_ITEMS=()
}
# Ensure cleanup on script exit
trap 'cleanup_scenario' EXIT
# Wait for container startup (looks for uwsgi or a known failure)
wait_for_ready() {
local name="$1"
local timeout="${2:-$STARTUP_TIMEOUT}"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
# Check if container is still running
if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then
echo " Container $name exited unexpectedly"
return 1
fi
# Success: uwsgi started
if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then
return 0
fi
# Known fatal: our error handler
if docker logs "$name" 2>&1 | grep -q "ERROR: Cannot update ownership"; then
echo " Container hit ownership error (expected in some tests)"
return 1
fi
sleep 3
((elapsed+=3))
done
echo " Timeout (${timeout}s) waiting for $name"
return 1
}
# Verify file/directory ownership
check_ownership() {
local container="$1" path="$2" expected_uid="$3" expected_gid="$4"
local actual
actual=$(docker exec "$container" stat -c '%u:%g' "$path" 2>/dev/null)
if [ "$actual" = "${expected_uid}:${expected_gid}" ]; then
log_pass "Ownership $path = $actual"
else
log_fail "Ownership $path: expected ${expected_uid}:${expected_gid}, got ${actual:-<error>}"
fi
}
# Verify file permissions (octal)
check_permissions() {
local container="$1" path="$2" expected="$3"
local actual
actual=$(docker exec "$container" stat -c '%a' "$path" 2>/dev/null)
if [ "$actual" = "$expected" ]; then
log_pass "Permissions $path = $actual"
else
log_fail "Permissions $path: expected $expected, got ${actual:-<error>}"
fi
}
# Verify PostgreSQL is accessible
check_pg_accessible() {
local container="$1" os_user="$2" db="${3:-dispatcharr}" port="${4:-5432}"
if docker exec "$container" su - "$os_user" -c \
"psql -d $db -p $port -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then
log_pass "PostgreSQL accessible as OS user '$os_user' (db=$db)"
else
log_fail "PostgreSQL not accessible as OS user '$os_user' (db=$db)"
fi
}
# Verify a PG role exists with superuser
check_role_superuser() {
local container="$1" os_user="$2" role="$3"
local result
result=$(docker exec "$container" su - "$os_user" -c \
"psql -d postgres -p 5432 -tAc \"SELECT rolsuper FROM pg_roles WHERE rolname='$role';\"" 2>/dev/null | tr -d ' ')
if [ "$result" = "t" ]; then
log_pass "PG role '$role' is superuser"
else
log_fail "PG role '$role' not superuser (got: '${result:-<missing>}')"
fi
}
# Capture docker logs to a temp file (avoids pipe issues on Windows/Docker Desktop)
_capture_logs() {
local container="$1" logfile="$2"
docker logs "$container" > "$logfile" 2>&1
}
# Verify no permission errors in logs
check_no_permission_errors() {
local container="$1"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
local errors
errors=$(grep -iE "permission denied|operation not permitted" "$tmplog" \
| grep -v "GPU acceleration" | grep -v "Warning:" | head -5)
rm -f "$tmplog"
if [ -n "$errors" ]; then
log_fail "Permission errors in logs:"
echo "$errors" | while read -r line; do echo " $line"; done
else
log_pass "No permission errors in logs"
fi
}
# Verify Django migrations completed (search full log, not just tail)
check_migrations_done() {
local container="$1"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -qE "Running migrations|No migrations to apply|Operations to perform|static files copied|Applying .+\.\.\. OK" "$tmplog"; then
log_pass "Django migrations completed"
elif grep -q "uwsgi started with PID" "$tmplog"; then
# uwsgi starts AFTER migrations — if it's running, migrations succeeded
log_pass "Django migrations completed (confirmed via uwsgi startup)"
else
log_fail "Django migrations did not complete"
fi
rm -f "$tmplog"
}
# Check that a log message appears (or does not)
check_log_contains() {
local container="$1" pattern="$2" description="$3"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -q "$pattern" "$tmplog"; then
log_pass "$description"
else
log_fail "$description (pattern not found: $pattern)"
fi
rm -f "$tmplog"
}
check_log_absent() {
local container="$1" pattern="$2" description="$3"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -q "$pattern" "$tmplog"; then
log_fail "$description (unexpected pattern found: $pattern)"
else
log_pass "$description"
fi
rm -f "$tmplog"
}
# Verify web UI responds (nginx -> uwsgi -> Django stack is functional).
# Retries briefly since uwsgi may still be connecting after startup log.
check_web_ui() {
local container="$1" port="${2:-9191}"
local retries=5 status=""
for ((i=1; i<=retries; i++)); do
status=$(docker exec "$container" python3 -c "
import urllib.request, urllib.error
try:
r = urllib.request.urlopen('http://localhost:$port/', timeout=10)
print(r.status)
except urllib.error.HTTPError as e:
print(e.code)
except:
print(0)
" 2>/dev/null | tr -d '[:space:]')
if [ -n "$status" ] && [ "$status" != "0" ] 2>/dev/null; then
break
fi
sleep 2
done
if [ -n "$status" ] && [ "$status" != "0" ] && [ "$status" -lt 500 ] 2>/dev/null; then
log_pass "Web UI responds (HTTP $status)"
else
log_fail "Web UI not responding (got: ${status:-<none>})"
fi
}
# Dump logs on failure (uses per-scenario tracking)
SCENARIO_FAIL_BEFORE=0
dump_logs_on_fail() {
local container="$1"
if [ $FAIL -gt $SCENARIO_FAIL_BEFORE ]; then
echo -e "\n ${RED}--- Last 60 lines of logs ---${NC}"
docker logs "$container" 2>&1 | tail -60 | sed 's/^/ | /'
echo -e " ${RED}--- End logs ---${NC}"
fi
}
# Create old-style PostgreSQL data by running the actual release image.
# This is the most realistic simulation of an upgrade — the release image
# runs its own entrypoint, initializing PG as the postgres system user
# with the real init scripts that users currently have.
setup_old_pg_data() {
local volume="$1"
local name="${TEST_PREFIX}_old_setup"
log_info "Creating old-style data using release image ($RELEASE_IMAGE)..."
docker rm -f "$name" 2>/dev/null
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-v "${volume}:/data" \
"$RELEASE_IMAGE" >/dev/null
# Wait for the release image to fully initialize (PG running + migrations)
local elapsed=0
while [ $elapsed -lt $STARTUP_TIMEOUT ]; do
if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then
log_info "Release image initialized successfully"
break
fi
if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then
log_info "Release image exited during init (checking data...)"
break
fi
sleep 3; ((elapsed+=3))
done
docker stop "$name" >/dev/null 2>&1
docker rm "$name" >/dev/null 2>&1
# Verify data was created (use --entrypoint to avoid running full app)
local owner
owner=$(docker run --rm --entrypoint stat -v "${volume}:/data" "$IMAGE_NAME" \
-c '%u:%g' /data/db/PG_VERSION 2>/dev/null)
log_info "Old data owner: ${owner:-<not found>}"
}
# Fallback: create old-style data manually (if release image unavailable)
setup_old_pg_data_manual() {
local volume="$1"
log_info "Initializing old-style PG data manually (UID 102, postgres superuser)..."
docker run --rm --entrypoint bash -v "${volume}:/data" "$IMAGE_NAME" -c '
PG_VER=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
PG_BIN=/usr/lib/postgresql/$PG_VER/bin
mkdir -p /data/db
chown -R postgres:postgres /data/db
chmod 700 /data/db
su - postgres -c "$PG_BIN/initdb -D /data/db"
su - postgres -c "$PG_BIN/pg_ctl -D /data/db start -w -o \"-c port=5432\""
su - postgres -c "psql -p 5432 -c \"CREATE ROLE dispatch WITH LOGIN PASSWORD '\''secret'\'';\""
su - postgres -c "createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr"
su - postgres -c "$PG_BIN/pg_ctl -D /data/db stop -w"
echo "Old PG data ready: $(stat -c "%u:%g" /data/db/PG_VERSION) on PG_VERSION"
'
}
###############################################################################
# Test Scenarios
###############################################################################
# Verifies a clean install with no PUID/PGID set defaults to 1000:1000.
# Checks: ownership, permissions, PG access, role, sentinel, migrations.
test_fresh_default() {
CURRENT_SCENARIO="fresh_default"
section "Fresh install — default config (no PUID/PGID)"
local name="${TEST_PREFIX}_fresh_def"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1000" "1000"
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000"
check_permissions "$name" "/data/db" "700"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
# Verify ownership sentinel was created
local sentinel_val
sentinel_val=$(docker exec "$name" cat /data/db/.owner_puid 2>/dev/null | tr -d '[:space:]')
if [ "$sentinel_val" = "1000:1000" ]; then
log_pass "Ownership sentinel created (1000:1000)"
else
log_fail "Ownership sentinel: expected 1000:1000, got ${sentinel_val:-<missing>}"
fi
else
log_fail "Container failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies fresh install with explicitly set PUID/PGID (non-default).
# Checks: ownership matches 1500:1500, PG accessible, role created.
test_fresh_custom_puid() {
CURRENT_SCENARIO="fresh_custom_puid"
section "Fresh install — PUID=1500 PGID=1500"
local name="${TEST_PREFIX}_fresh_puid"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1500 -e PGID=1500 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1500" "1500"
check_ownership "$name" "/data/db/PG_VERSION" "1500" "1500"
check_ownership "$name" "/data/db/pg_hba.conf" "1500" "1500"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
else
log_fail "Container failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Simulates upgrading from pre-PUID image (data owned by UID 102) with
# explicit PUID=1000. Verifies ownership migrates, roles are promoted,
# and the postgres role is preserved for rollback compatibility.
test_upgrade_explicit_puid() {
CURRENT_SCENARIO="upgrade_explicit_puid"
section "Upgrade — old UID 102 data, explicit PUID=1000"
local name="${TEST_PREFIX}_upg_puid"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
if [ "$USE_RELEASE_IMAGE" = true ]; then
setup_old_pg_data "$vol"
else
setup_old_pg_data_manual "$vol"
fi
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
# Ownership should have migrated to 1000:1000
check_ownership "$name" "/data/db" "1000" "1000"
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000"
check_permissions "$name" "/data/db" "700"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
# Rollback compatibility: postgres role still superuser
check_role_superuser "$name" "dispatch" "postgres"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "Migrating PostgreSQL data ownership" \
"Ownership migration logged"
check_log_contains "$name" "Application role configured" \
"Role setup executed"
else
log_fail "Container failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Simulates upgrade without PUID set. Auto-adapt should detect the
# existing data owner (UID 102) and skip ownership migration entirely.
test_upgrade_auto_adapt() {
CURRENT_SCENARIO="upgrade_auto_adapt"
section "Upgrade — old UID 102 data, no PUID (auto-adapt)"
local name="${TEST_PREFIX}_upg_auto"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
if [ "$USE_RELEASE_IMAGE" = true ]; then
setup_old_pg_data "$vol"
else
setup_old_pg_data_manual "$vol"
fi
# No PUID/PGID — should auto-adapt to data owner (UID 102)
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
# Data should stay at original UID — no migration. GID depends on
# the postgres group GID in the release image (typically 104).
local actual_owner
actual_owner=$(docker exec "$name" stat -c '%u:%g' /data/db/PG_VERSION 2>/dev/null)
local expected_uid="102"
local actual_uid="${actual_owner%%:*}"
if [ "$actual_uid" = "$expected_uid" ]; then
log_pass "Ownership /data/db/PG_VERSION UID = $actual_uid (auto-adapted)"
else
log_fail "Ownership /data/db/PG_VERSION UID: expected $expected_uid, got $actual_uid"
fi
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "PUID not set" \
"Auto-adapt logged"
check_log_absent "$name" "Migrating PostgreSQL data ownership" \
"No ownership migration (correctly skipped)"
else
log_fail "Container failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies that restarting a container on existing data is idempotent:
# no unnecessary chown, no migration logged, sentinel skip works.
test_restart_idempotent() {
CURRENT_SCENARIO="restart_idempotent"
section "Container restart — idempotent (same PUID)"
local name="${TEST_PREFIX}_restart"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
# First run
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if ! wait_for_ready "$name"; then
log_fail "First run failed to start"
dump_logs_on_fail "$name"
cleanup_scenario
return
fi
log_pass "First run started successfully"
# Stop and restart
docker stop "$name" >/dev/null
docker rm "$name" >/dev/null
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
track_container "$name"
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_absent "$name" "Migrating PostgreSQL data ownership" \
"No migration on restart"
else
log_fail "Restart failed"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies that changing PUID between restarts triggers ownership migration.
# First run: PUID=1000. Second run: PUID=2000 — should chown all PG data.
test_puid_change() {
CURRENT_SCENARIO="puid_change"
section "PUID change between restarts (1000 → 2000)"
local name="${TEST_PREFIX}_puidchg"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
# First run with PUID=1000
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if ! wait_for_ready "$name"; then
log_fail "First run (PUID=1000) failed"
dump_logs_on_fail "$name"
cleanup_scenario
return
fi
log_pass "First run (PUID=1000) started"
docker stop "$name" >/dev/null; docker rm "$name" >/dev/null
# Second run with PUID=2000
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=2000 -e PGID=2000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
track_container "$name"
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "2000" "2000"
check_ownership "$name" "/data/db/PG_VERSION" "2000" "2000"
check_ownership "$name" "/data/db/pg_hba.conf" "2000" "2000"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "Migrating PostgreSQL data ownership" \
"Ownership migration logged for PUID change"
else
log_fail "Second run (PUID=2000) failed"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies PUID=102 (which collides with the postgres system user UID).
# 01-user-setup.sh renames the postgres user to $POSTGRES_USER — this
# should be harmless since all operations use $POSTGRES_USER, not 'postgres'.
test_uid_collision_102() {
CURRENT_SCENARIO="uid_collision_102"
section "PUID=102 — collision with postgres system user"
local name="${TEST_PREFIX}_uid102"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=102 -e PGID=102 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "102" "102"
check_ownership "$name" "/data/db/PG_VERSION" "102" "102"
check_ownership "$name" "/data/db/pg_hba.conf" "102" "102"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
log_pass "PUID=102 collision handled correctly"
else
log_fail "Container failed to start with PUID=102"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies PUID=0 is rejected early with a clear error message.
# PostgreSQL refuses to run as root, and reassigning UID 0 would
# rename the root user inside the container.
test_puid_zero() {
CURRENT_SCENARIO="puid_zero"
section "PUID=0 — rejected (PostgreSQL cannot run as root)"
local name="${TEST_PREFIX}_uid0"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=0 -e PGID=0 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
# Container should exit quickly with an error
sleep 5
if docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then
log_fail "Container should have exited with PUID=0"
else
log_pass "Container exited (expected with PUID=0)"
fi
check_log_contains "$name" "PUID=0 or PGID=0 is not supported" \
"Clear error message for PUID=0"
check_log_absent "$name" "Initializing PostgreSQL" \
"PostgreSQL init was not attempted"
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies non-numeric PUID/PGID values are rejected early with
# a clear error message before any user/group manipulation.
test_puid_non_numeric() {
CURRENT_SCENARIO="puid_non_numeric"
section "PUID=abc — rejected (must be positive integer)"
local name="${TEST_PREFIX}_nonnumeric"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=abc -e PGID=xyz \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
sleep 5
if docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then
log_fail "Container should have exited with non-numeric PUID"
else
log_pass "Container exited (expected with non-numeric PUID)"
fi
check_log_contains "$name" "PUID and PGID must be positive integers" \
"Clear error message for non-numeric PUID"
check_log_absent "$name" "Initializing PostgreSQL" \
"PostgreSQL init was not attempted"
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies fresh install works on a bind mount (host directory).
# Uses Docker-managed /tmp to avoid Windows path conversion issues.
test_bind_mount() {
CURRENT_SCENARIO="bind_mount"
section "Bind mount — local filesystem"
local name="${TEST_PREFIX}_bind"
local hostdir
# Create a temp directory for the bind mount
hostdir=$(docker run --rm "$IMAGE_NAME" bash -c "mktemp -d /tmp/puid_test_bind.XXXXXX" 2>/dev/null)
if [ -z "$hostdir" ]; then
# Fallback: create on Windows host via Docker
hostdir="/tmp/puid_test_bind_$$"
fi
cleanup_scenario
track_container "$name"
# Use a Docker-managed temp dir to avoid Windows path issues
# Create the bind mount dir inside a helper container, then use it
docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c "
mkdir -p /hosttemp/puid_test_bind_$$
chmod 777 /hosttemp/puid_test_bind_$$
" 2>/dev/null
local bind_path="/tmp/puid_test_bind_$$"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${bind_path}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1000" "1000"
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
log_pass "Bind mount fresh install works"
else
log_fail "Bind mount fresh install failed"
fi
dump_logs_on_fail "$name"
# Clean up bind mount
docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \
"rm -rf /hosttemp/puid_test_bind_$$" 2>/dev/null
cleanup_scenario
}
# Verifies ownership migration works on bind mounts (UID 102 -> 1000).
# Creates old-style PG data manually, then starts with new image.
test_bind_mount_upgrade() {
CURRENT_SCENARIO="bind_mount_upgrade"
section "Bind mount upgrade — old UID 102 → PUID=1000"
local name="${TEST_PREFIX}_bind_upg"
local bind_path="/tmp/puid_test_bind_upg_$$"
cleanup_scenario
track_container "$name"
# Create bind mount dir with old-style PG data (UID 102)
docker run --rm -v /tmp:/hosttemp --entrypoint bash "$IMAGE_NAME" -c "
mkdir -p /hosttemp/puid_test_bind_upg_$$/db
PG_VER=\$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
PG_BIN=/usr/lib/postgresql/\$PG_VER/bin
chown -R postgres:postgres /hosttemp/puid_test_bind_upg_$$/db
chmod 700 /hosttemp/puid_test_bind_upg_$$/db
su - postgres -c \"\$PG_BIN/initdb -D /hosttemp/puid_test_bind_upg_$$/db\"
su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_upg_$$/db start -w -o '-c port=5432'\"
su - postgres -c \"psql -p 5432 -c \\\"CREATE ROLE dispatch WITH LOGIN PASSWORD 'secret';\\\"\"
su - postgres -c \"createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr\"
su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_upg_$$/db stop -w\"
"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${bind_path}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1000" "1000"
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "Migrating PostgreSQL data ownership" \
"Bind mount ownership migration logged"
else
log_fail "Bind mount upgrade failed"
fi
dump_logs_on_fail "$name"
docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \
"rm -rf /hosttemp/puid_test_bind_upg_$$" 2>/dev/null
cleanup_scenario
}
# Verifies auto-adapt works on bind mounts: no PUID set, data owned by
# UID 102 — should auto-detect and skip migration entirely.
test_bind_mount_auto_adapt() {
CURRENT_SCENARIO="bind_mount_auto_adapt"
section "Bind mount upgrade — no PUID (auto-adapt to UID 102)"
local name="${TEST_PREFIX}_bind_auto"
local bind_path="/tmp/puid_test_bind_auto_$$"
cleanup_scenario
track_container "$name"
# Create bind mount dir with old-style data
docker run --rm -v /tmp:/hosttemp --entrypoint bash "$IMAGE_NAME" -c "
mkdir -p /hosttemp/puid_test_bind_auto_$$/db
PG_VER=\$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
PG_BIN=/usr/lib/postgresql/\$PG_VER/bin
chown -R postgres:postgres /hosttemp/puid_test_bind_auto_$$/db
chmod 700 /hosttemp/puid_test_bind_auto_$$/db
su - postgres -c \"\$PG_BIN/initdb -D /hosttemp/puid_test_bind_auto_$$/db\"
su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_auto_$$/db start -w -o '-c port=5432'\"
su - postgres -c \"psql -p 5432 -c \\\"CREATE ROLE dispatch WITH LOGIN PASSWORD 'secret';\\\"\"
su - postgres -c \"createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr\"
su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_auto_$$/db stop -w\"
"
# No PUID — auto-adapt should match data owner
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-v "${bind_path}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
# Should stay at original UID — no migration. GID depends on the
# postgres group GID in the image (typically 104, not 102).
local actual_owner
actual_owner=$(docker exec "$name" stat -c '%u:%g' /data/db/PG_VERSION 2>/dev/null)
local expected_uid="102"
local actual_uid="${actual_owner%%:*}"
if [ "$actual_uid" = "$expected_uid" ]; then
log_pass "Ownership /data/db/PG_VERSION UID = $actual_uid (auto-adapted)"
else
log_fail "Ownership /data/db/PG_VERSION UID: expected $expected_uid, got $actual_uid"
fi
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "PUID not set" \
"Auto-adapt logged on bind mount"
check_log_absent "$name" "Migrating PostgreSQL data ownership" \
"No migration on auto-adapted bind mount"
else
log_fail "Bind mount auto-adapt failed"
fi
dump_logs_on_fail "$name"
docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \
"rm -rf /hosttemp/puid_test_bind_auto_$$" 2>/dev/null
cleanup_scenario
}
# Verifies modular mode (external PostgreSQL + Redis). Internal PG setup
# should be completely skipped. No /data/db directory should be created.
test_modular_mode() {
CURRENT_SCENARIO="modular_mode"
section "Modular mode — external PostgreSQL + Redis"
local name="${TEST_PREFIX}_modular"
local net="${TEST_PREFIX}_modular_net"
local pg_name="${TEST_PREFIX}_modular_pg"
local redis_name="${TEST_PREFIX}_modular_redis"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
# Start external PostgreSQL
docker run -d --name "$pg_name" --network "$net" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
postgres:17 >/dev/null
# Start external Redis
docker run -d --name "$redis_name" --network "$net" \
redis:latest >/dev/null
# Wait for PG to be ready
local elapsed=0
while [ $elapsed -lt 30 ]; do
if docker exec "$pg_name" pg_isready -U dispatch 2>/dev/null | grep -q "accepting"; then
break
fi
sleep 2; ((elapsed+=2))
done
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
# Verify NO internal PG data created
if docker exec "$name" test -f /data/db/PG_VERSION 2>/dev/null; then
log_fail "/data/db/PG_VERSION exists in modular mode"
else
log_pass "No internal PG data in modular mode"
fi
check_log_absent "$name" "Migrating PostgreSQL data ownership" \
"No ownership migration in modular mode"
check_log_absent "$name" "Initializing PostgreSQL database" \
"No PG init in modular mode"
check_log_contains "$name" "Modular mode" \
"Modular mode detected"
check_no_permission_errors "$name"
check_migrations_done "$name"
else
log_fail "Modular mode failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies that a non-default POSTGRES_USER name works end-to-end.
# All init scripts use $POSTGRES_USER, so "myapp" should work identically.
test_custom_postgres_user() {
CURRENT_SCENARIO="custom_postgres_user"
section "Custom POSTGRES_USER=myapp"
local name="${TEST_PREFIX}_custuser"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-e POSTGRES_USER=myapp \
-e POSTGRES_DB=myappdb \
-e POSTGRES_PASSWORD=mypassword \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1000" "1000"
check_pg_accessible "$name" "myapp" "myappdb"
check_role_superuser "$name" "myapp" "myapp"
check_no_permission_errors "$name"
check_migrations_done "$name"
log_pass "Custom POSTGRES_USER works"
else
log_fail "Custom POSTGRES_USER failed"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies that a non-default POSTGRES_PORT works. PostgreSQL should
# listen on port 5433, and all operations should use that port.
test_custom_port() {
CURRENT_SCENARIO="custom_port"
section "Custom POSTGRES_PORT=5433"
local name="${TEST_PREFIX}_custport"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-e POSTGRES_PORT=5433 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
# Verify PG is on the custom port
if docker exec "$name" su - dispatch -c \
"psql -d dispatcharr -p 5433 -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then
log_pass "PostgreSQL running on custom port 5433"
else
log_fail "PostgreSQL not accessible on port 5433"
fi
check_no_permission_errors "$name"
check_migrations_done "$name"
else
log_fail "Custom port failed"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies fresh install works on ephemeral tmpfs storage.
# Data is lost on restart — this tests that init scripts handle
# an empty /data directory correctly every time.
test_tmpfs_volume() {
CURRENT_SCENARIO="tmpfs_volume"
section "tmpfs volume — ephemeral storage"
local name="${TEST_PREFIX}_tmpfs"
cleanup_scenario
track_container "$name"
docker run -d --name "$name" \
--tmpfs /data:rw,size=512m \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_ownership "$name" "/data/db" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
log_pass "tmpfs volume works (ephemeral, data lost on restart)"
else
log_fail "tmpfs volume failed"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Tests both ownership migration (UID 999 -> 1000) AND PostgreSQL major
# version upgrade (16 -> 17) simultaneously. Uses official postgres:16
# image to create realistic PG 16 data with UID 999 (postgres in that image).
# Requires postgres:16 image to be available.
test_pg_major_upgrade() {
CURRENT_SCENARIO="pg_major_upgrade"
section "PostgreSQL major version upgrade (16 → 17)"
if [ "$PG16_AVAILABLE" != true ]; then
log_skip "postgres:16 image not available — skipping"
return
fi
local name="${TEST_PREFIX}_pgupg"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
# Create a PG 16 data cluster using the official postgres:16 image.
# This simulates an older Dispatcharr installation that used PG 16.
# The postgres user in the official image has UID 999.
log_info "Creating PG 16 data cluster..."
if ! docker run --rm -v "${vol}:/data" --entrypoint bash postgres:16 -c '
mkdir -p /data/db
chown -R postgres:postgres /data/db
chmod 700 /data/db
gosu postgres /usr/lib/postgresql/16/bin/initdb -D /data/db
gosu postgres /usr/lib/postgresql/16/bin/pg_ctl -D /data/db start -w -o "-c port=5432"
gosu postgres psql -p 5432 -c "CREATE ROLE dispatch WITH LOGIN PASSWORD '\''secret'\'';"
gosu postgres createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr
gosu postgres /usr/lib/postgresql/16/bin/pg_ctl -D /data/db stop -w
echo "PG 16 data created: $(cat /data/db/PG_VERSION)"
'; then
log_fail "Failed to create PG 16 data cluster"
cleanup_scenario
return
fi
# Verify PG 16 data exists
local pg_ver
pg_ver=$(docker run --rm -v "${vol}:/data" --entrypoint cat "$IMAGE_NAME" \
/data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]')
if [ "$pg_ver" = "16" ]; then
log_pass "PG 16 data cluster created (owned by UID 999)"
else
log_fail "PG_VERSION expected 16, got ${pg_ver:-<missing>}"
cleanup_scenario
return
fi
# Run our image (PG 17) against the PG 16 data.
# This tests BOTH ownership migration (999->1000) AND pg_upgrade (16->17).
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
# pg_upgrade + apt install of PG 16 binaries takes longer than normal startup
if wait_for_ready "$name" 300; then
# Verify data was upgraded to PG 17
local new_ver
new_ver=$(docker exec "$name" cat /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]')
if [ "$new_ver" = "17" ]; then
log_pass "PG data upgraded to version 17"
else
log_fail "PG data version: expected 17, got ${new_ver:-<missing>}"
fi
check_ownership "$name" "/data/db" "1000" "1000"
check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
check_log_contains "$name" "Migrating PostgreSQL data ownership" \
"Ownership migration logged (999→1000)"
check_log_contains "$name" "upgrading to" \
"PG version upgrade logged"
check_log_contains "$name" "Upgrade complete" \
"PG upgrade completion logged"
# Verify old data was backed up
if docker exec "$name" bash -c 'ls -d /data/db_backup_16_* 2>/dev/null' | grep -q "backup"; then
log_pass "Old PG 16 data backed up"
else
log_fail "No backup of old PG 16 data found"
fi
else
log_fail "Container failed to start after pg_upgrade"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Tests PG major upgrade on data that was created by the post-PUID image
# (install user = "dispatch", not "postgres"). This validates the future
# upgrade path where pg_upgrade -U must use "dispatch" instead of "postgres".
# Requires postgres:16 image to be available.
test_pg_upgrade_post_puid() {
CURRENT_SCENARIO="pg_upgrade_post_puid"
section "PostgreSQL major upgrade — post-PUID data (install user = dispatch)"
if [ "$PG16_AVAILABLE" != true ]; then
log_skip "postgres:16 image not available — skipping"
return
fi
local name="${TEST_PREFIX}_pgupg2"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
# Create a PG 16 data cluster that simulates data created by the
# post-PUID image: owned by UID 1000, install user = "dispatch".
# This tests the upgrade path for future PG version bumps where
# the install user is $POSTGRES_USER, not "postgres".
log_info "Creating PG 16 data cluster with dispatch as install user..."
if ! docker run --rm -v "${vol}:/data" --entrypoint bash "$IMAGE_NAME" -c '
# Install PG 16 binaries
apt-get update -qq && apt-get install -y -qq postgresql-16 >/dev/null 2>&1
# Create dispatch user (matches PUID=1000 scenario)
groupadd -g 1000 dispatch 2>/dev/null || true
useradd -u 1000 -g 1000 -m dispatch 2>/dev/null || true
mkdir -p /data/db
chown -R 1000:1000 /data/db
chmod 700 /data/db
# Initialize with dispatch as the install user (bootstrap superuser)
su - dispatch -c "/usr/lib/postgresql/16/bin/initdb -U dispatch -D /data/db"
# Ensure socket directory is writable by dispatch
mkdir -p /var/run/postgresql
chown 1000:1000 /var/run/postgresql
# Start, create app database, stop
su - dispatch -c "/usr/lib/postgresql/16/bin/pg_ctl -D /data/db start -w -o \"-c port=5432\""
su - dispatch -c "psql -U dispatch -d template1 -p 5432 -c \"CREATE DATABASE dispatcharr OWNER dispatch ENCODING '\''UTF8'\'';\""
su - dispatch -c "/usr/lib/postgresql/16/bin/pg_ctl -D /data/db stop -w"
echo "PG 16 (post-PUID) data created: $(cat /data/db/PG_VERSION)"
'; then
log_fail "Failed to create PG 16 post-PUID data cluster"
cleanup_scenario
return
fi
# Verify PG 16 data exists with correct ownership
local pg_ver
pg_ver=$(docker run --rm -v "${vol}:/data" --entrypoint cat "$IMAGE_NAME" \
/data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]')
if [ "$pg_ver" = "16" ]; then
log_pass "PG 16 post-PUID data cluster created (owned by UID 1000)"
else
log_fail "PG_VERSION expected 16, got ${pg_ver:-<missing>}"
cleanup_scenario
return
fi
# Run our image (PG 17) against the post-PUID PG 16 data.
# This tests pg_upgrade when install user = "dispatch" (not "postgres").
# No ownership migration should occur (already UID 1000).
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name" 300; then
# Verify data was upgraded to PG 17
local new_ver
new_ver=$(docker exec "$name" cat /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]')
if [ "$new_ver" = "17" ]; then
log_pass "PG data upgraded to version 17"
else
log_fail "PG data version: expected 17, got ${new_ver:-<missing>}"
fi
check_ownership "$name" "/data/db" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_role_superuser "$name" "dispatch" "dispatch"
check_no_permission_errors "$name"
check_migrations_done "$name"
# Key assertion: install user detected as "dispatch", not "postgres"
check_log_contains "$name" "Old cluster install user: dispatch" \
"Install user detected as dispatch (post-PUID path)"
# No ownership migration should have occurred
check_log_absent "$name" "Migrating PostgreSQL data ownership" \
"No ownership migration (UID already matches)"
check_log_contains "$name" "upgrading to" \
"PG version upgrade logged"
check_log_contains "$name" "Upgrade complete" \
"PG upgrade completion logged"
# Verify old data was backed up
if docker exec "$name" bash -c 'ls -d /data/db_backup_16_* 2>/dev/null' | grep -q "backup"; then
log_pass "Old PG 16 data backed up"
else
log_fail "No backup of old PG 16 data found"
fi
else
log_fail "Container failed to start after post-PUID pg_upgrade"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Verifies the full HTTP stack after startup: nginx serves the frontend,
# uwsgi proxies to Django, and static files are collected. Uses an HTTP
# request from inside the container (no port mapping needed).
test_e2e_web_ui() {
CURRENT_SCENARIO="e2e_web_ui"
section "End-to-end — full HTTP stack (nginx → uwsgi → Django)"
local name="${TEST_PREFIX}_e2e"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
docker run -d --name "$name" \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
check_pg_accessible "$name" "dispatch"
check_migrations_done "$name"
# Verify the full HTTP stack responds
check_web_ui "$name"
# Verify static files were collected (collectstatic runs before uwsgi)
if docker exec "$name" test -d /app/static 2>/dev/null; then
log_pass "Static files directory exists"
else
log_fail "Static files directory missing"
fi
check_no_permission_errors "$name"
else
log_fail "Container failed to start"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
# Tests startup with a read-only root filesystem (security hardening).
# This is an ambitious test — nginx/uwsgi need writable paths provided
# via tmpfs. The test verifies that PUID init scripts specifically don't
# fail; non-PUID failures (missing tmpfs mounts) are skipped, not failed.
test_readonly_rootfs() {
CURRENT_SCENARIO="readonly_rootfs"
section "Read-only root filesystem (security hardened)"
local name="${TEST_PREFIX}_rofs"
local vol="${name}_data"
cleanup_scenario
fresh_volume "$vol"
track_container "$name"
# Read-only rootfs requires tmpfs for writable paths
docker run -d --name "$name" \
--read-only \
--tmpfs /tmp:rw \
--tmpfs /run:rw \
--tmpfs /var/run:rw \
--tmpfs /var/log:rw \
--tmpfs /etc:rw \
--tmpfs /root:rw \
--tmpfs /app/static:rw \
--tmpfs /app/media:rw \
--tmpfs /app/logo_cache:rw \
-e DISPATCHARR_ENV=aio \
-e PUID=1000 -e PGID=1000 \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name" 120; then
check_ownership "$name" "/data/db" "1000" "1000"
check_pg_accessible "$name" "dispatch"
check_no_permission_errors "$name"
log_pass "Read-only rootfs works"
else
# Check if it's our PUID code that broke or something else (e.g., can't
# write to /etc, nginx needs writable paths, etc.)
local ro_errors
ro_errors=$(docker logs "$name" 2>&1 | grep -iE "read-only file system|No such file or directory" | head -3)
if [ -n "$ro_errors" ]; then
log_skip "Read-only rootfs: non-PUID failure (expected — needs more tmpfs mounts)"
elif docker logs "$name" 2>&1 | grep -iE "Cannot update ownership|permission denied" | grep -q "/data/"; then
log_fail "Read-only rootfs: PUID-related failure"
else
log_skip "Read-only rootfs: unrelated startup failure"
fi
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
###############################################################################
# Main
###############################################################################
echo -e "${BOLD}"
echo "╔══════════════════════════════════════════╗"
echo "║ PUID/PGID Docker Init Test Suite ║"
echo "╚══════════════════════════════════════════╝"
echo -e "${NC}"
# Pull required images
section "Pulling required images"
if docker pull "$RELEASE_IMAGE" 2>/dev/null; then
log_pass "Release image pulled ($RELEASE_IMAGE)"
USE_RELEASE_IMAGE=true
else
log_info "Could not pull release image — upgrade tests will use manual setup"
USE_RELEASE_IMAGE=false
fi
if docker pull postgres:17 2>/dev/null; then
log_pass "PostgreSQL 17 image pulled"
else
log_info "postgres:17 not available — modular test will be skipped"
fi
docker pull redis:latest 2>/dev/null && log_pass "Redis image pulled" || true
PG16_AVAILABLE=false
if docker pull postgres:16 2>/dev/null; then
log_pass "PostgreSQL 16 image pulled (for upgrade test)"
PG16_AVAILABLE=true
else
log_info "postgres:16 not available — pg_major_upgrade test will be skipped"
fi
# Build test image from local changes
if [ "$SKIP_BUILD" = false ]; then
section "Building test image from local changes"
if docker build -t "$IMAGE_NAME" -f docker/Dockerfile . ; then
log_pass "Test image built ($IMAGE_NAME)"
else
echo -e "${RED}Image build failed. Aborting.${NC}"
exit 1
fi
else
log_info "Skipping build (--skip-build)"
fi
# Define scenario list
SCENARIOS=(
fresh_default
fresh_custom_puid
upgrade_explicit_puid
upgrade_auto_adapt
restart_idempotent
puid_change
uid_collision_102
puid_zero
puid_non_numeric
bind_mount
bind_mount_upgrade
bind_mount_auto_adapt
modular_mode
custom_postgres_user
custom_port
tmpfs_volume
pg_major_upgrade
pg_upgrade_post_puid
e2e_web_ui
readonly_rootfs
)
# Run scenarios
for scenario in "${SCENARIOS[@]}"; do
if [ -n "$SINGLE_SCENARIO" ] && [ "$scenario" != "$SINGLE_SCENARIO" ]; then
continue
fi
"test_${scenario}"
done
# Summary
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════╗"
echo -e "║ RESULTS ║"
echo -e "╚══════════════════════════════════════════╝${NC}"
echo -e " ${GREEN}Passed: $PASS${NC}"
echo -e " ${RED}Failed: $FAIL${NC}"
echo -e " ${YELLOW}Skipped: $SKIP${NC}"
if [ ${#ERRORS[@]} -gt 0 ]; then
echo ""
echo -e "${RED}Failures:${NC}"
for err in "${ERRORS[@]}"; do
echo -e " ${RED}$err${NC}"
done
fi
echo ""
if [ $FAIL -eq 0 ]; then
echo -e "${GREEN}${BOLD}All tests passed!${NC}"
exit 0
else
echo -e "${RED}${BOLD}$FAIL test(s) failed.${NC}"
exit 1
fi