From 52ed0fc15a6fb51f4b35bc41358103ed34ec4ebe Mon Sep 17 00:00:00 2001 From: None <190783071+CodeBormen@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:35:38 -0500 Subject: [PATCH] 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 --- docker/entrypoint.sh | 11 +- docker/init/01-user-setup.sh | 52 +- docker/init/02-postgres.sh | 377 +++++-- docker/init/03-init-dispatcharr.sh | 8 +- docker/tests/test-puid-pgid.sh | 1517 ++++++++++++++++++++++++++++ 5 files changed, 1892 insertions(+), 73 deletions(-) create mode 100644 docker/tests/test-puid-pgid.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7d2a1bf6..3cbb4f1d 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -156,15 +156,20 @@ echo "Starting init process..." # Start PostgreSQL if NOT in modular mode (using external database) if [[ "$DISPATCHARR_ENV" != "modular" ]]; then echo "Starting Postgres..." - su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + su - $POSTGRES_USER -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" # Wait for PostgreSQL to be ready - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + until su - $POSTGRES_USER -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do echo_with_timestamp "Waiting for PostgreSQL to be ready..." sleep 1 done - postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') + postgres_pid=$(su - $POSTGRES_USER -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') echo "✅ Postgres started with PID $postgres_pid" pids+=("$postgres_pid") + + # Unconditional startup guarantees — run on every AIO startup. + # Each is idempotent and handles all scenarios (fresh, upgrade, restart). + promote_app_role + ensure_app_database else echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" # Wait for external PostgreSQL to be ready using pg_isready (checks actual protocol readiness) diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh index 1b0e0a27..330309ab 100644 --- a/docker/init/01-user-setup.sh +++ b/docker/init/01-user-setup.sh @@ -1,9 +1,59 @@ #!/bin/bash -# Set up user details +# NOTE: PUID/PGID values matching internal system UIDs (e.g. 102 for the +# postgres package user) will cause that OS user/group to be renamed to +# $POSTGRES_USER inside the container. This is cosmetic and does not affect +# runtime behavior since all postgres operations run as $POSTGRES_USER +# rather than the postgres system user. + +# Auto-detect PUID/PGID from existing data when not explicitly set. +# Avoids a cross-UID chown on upgrade, which would fail on restricted +# filesystems (NFS root_squash, CIFS). UID/GID 0 is excluded — PostgreSQL +# refuses to run as root. Falls through to default 1000 for new installs. +if [ -z "${PUID+x}" ] && [ -f "${POSTGRES_DIR}/PG_VERSION" ]; then + _data_uid=$(stat -c '%u' "${POSTGRES_DIR}/PG_VERSION") + if [ "$_data_uid" -ne 0 ] 2>/dev/null; then + export PUID=$_data_uid + echo "PUID not set — defaulting to existing data owner UID: $PUID" + fi +fi +if [ -z "${PGID+x}" ] && [ -f "${POSTGRES_DIR}/PG_VERSION" ]; then + _data_gid=$(stat -c '%g' "${POSTGRES_DIR}/PG_VERSION") + if [ "$_data_gid" -ne 0 ] 2>/dev/null; then + export PGID=$_data_gid + echo "PGID not set — defaulting to existing data owner GID: $PGID" + fi +fi export PUID=${PUID:-1000} export PGID=${PGID:-1000} +# Validate PUID/PGID are positive integers before any user/group operations. +# Non-numeric values would cause useradd/groupadd to fail with confusing errors. +if ! [[ "$PUID" =~ ^[0-9]+$ ]] || ! [[ "$PGID" =~ ^[0-9]+$ ]]; then + echo "" + echo "================================================================" + echo "ERROR: PUID and PGID must be positive integers." + echo " PUID=$PUID PGID=$PGID" + echo " Please set valid numeric values (default: 1000)." + echo "================================================================" + echo "" + exit 1 +fi + +# PostgreSQL refuses to run as root (UID 0). Block early — before any +# user/group manipulation — to prevent renaming the root user/group, +# which would break the container. +if [ "$PUID" = "0" ] || [ "$PGID" = "0" ]; then + echo "" + echo "================================================================" + echo "ERROR: PUID=0 or PGID=0 is not supported." + echo " PostgreSQL cannot run as root (UID 0)." + echo " Please set PUID and PGID to a non-zero value (default: 1000)." + echo "================================================================" + echo "" + exit 1 +fi + # Check if group with PGID exists if getent group "$PGID" >/dev/null 2>&1; then # Group exists, check if it's named correctly (should match POSTGRES_USER) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 2ece526f..7e9d8602 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -3,8 +3,41 @@ # Skip internal PostgreSQL setup in modular mode (using external database) if [[ "$DISPATCHARR_ENV" != "modular" ]]; then - # Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove - # some time in the future. + # Ensure the PostgreSQL socket directory is writable by the application user. + # The package installs this owned by the postgres system user, but PostgreSQL + # runs as $POSTGRES_USER (PUID:PGID) in AIO mode. + if [ -d /var/run/postgresql ]; then + chown $PUID:$PGID /var/run/postgresql + fi + + # Record PUID:PGID in a sentinel file so subsequent startups can skip + # the expensive recursive chown when ownership is already correct. + write_ownership_sentinel() { + echo "$PUID:$PGID" > "${POSTGRES_DIR}/.owner_puid" + chown "$PUID:$PGID" "${POSTGRES_DIR}/.owner_puid" + } + + # Write standard pg_hba.conf and enable network listening. + # Local (Unix socket): trust — safe for single-app containers where only + # authorized processes connect. Network: password required via md5. + # Idempotent: safe to call on every startup. + configure_pg_network() { + local datadir="$1" + cat > "${datadir}/pg_hba.conf" <> "${datadir}/postgresql.conf" + } + + # Legacy migration: move data from /data root into $POSTGRES_DIR. + # Safe to remove once all deployments have upgraded past this layout. if [ -e "/data/postgresql.conf" ]; then echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." @@ -24,7 +57,7 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then rmdir /tmp/postgres_migration # Set proper ownership and permissions for PostgreSQL data directory - chown -R postgres:postgres $POSTGRES_DIR + chown -R $PUID:$PGID $POSTGRES_DIR chmod 700 $POSTGRES_DIR echo "Migration completed successfully." @@ -39,6 +72,80 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then CURRENT_VERSION="" fi + # ========================================================================= + # Existing data: ensure ownership, auth, and permissions are correct. + # These guarantees run on EVERY startup with existing data — not just + # upgrades. This eliminates conditional edge cases and ensures the + # container always reaches a known-good state regardless of how the + # data was originally created. + # ========================================================================= + if [ -n "$CURRENT_VERSION" ] && [ -d "$POSTGRES_DIR" ]; then + + # --- 1. Ownership reconciliation (conditional — only when needed) --- + # Two triggers cause a recursive chown: + # a) PG_VERSION owner doesn't match PUID (obvious mismatch) + # b) Sentinel file (.owner_puid) missing or stale — catches partial + # chown from a previous interrupted startup where early files + # (including PG_VERSION) got the new owner but deeper files didn't. + # After a successful chown, the sentinel records PUID:PGID so + # subsequent startups skip the expensive recursive operation. + OWNERSHIP_SENTINEL="${POSTGRES_DIR}/.owner_puid" + CURRENT_OWNER=$(stat -c '%u' "$PG_VERSION_FILE") + _needs_chown=false + if [ "$CURRENT_OWNER" != "$PUID" ]; then + _needs_chown=true + elif [ ! -f "$OWNERSHIP_SENTINEL" ] || [ "$(cat "$OWNERSHIP_SENTINEL" 2>/dev/null)" != "$PUID:$PGID" ]; then + # Sentinel missing or stale. Could be: + # a) First startup with sentinel code (pre-existing data) — benign + # b) Interrupted chown from a previous startup — needs re-chown + # Spot-check a deeper directory to distinguish: if base/ also + # matches PUID:PGID, ownership is likely consistent (case a). + _deeper_check=$(stat -c '%u:%g' "${POSTGRES_DIR}/base" 2>/dev/null) + if [ "$_deeper_check" != "$PUID:$PGID" ]; then + _needs_chown=true + else + # Spot-check passed — ownership is consistent, record sentinel + # so future startups skip the spot-check entirely. + write_ownership_sentinel + fi + fi + + if [ "$_needs_chown" = true ]; then + echo "Migrating PostgreSQL data ownership from UID $CURRENT_OWNER to $PUID:$PGID..." + echo " This may take several minutes for large databases. Do not stop the container." + if ! chown -R "$PUID:$PGID" "$POSTGRES_DIR" 2>/dev/null; then + echo "" + echo "================================================================" + echo "ERROR: Cannot update ownership of $POSTGRES_DIR" + echo " Current owner: UID $CURRENT_OWNER" + echo " Target owner: UID $PUID (GID $PGID)" + echo "" + echo " This typically occurs with rootless Docker or restricted" + echo " filesystems (NFS with root_squash, CIFS/SMB)." + echo "" + echo " To fix:" + echo " - Local/NFS: sudo chown -R $PUID:$PGID /db" + echo " - CIFS/SMB: set the mount uid=$PUID,gid=$PGID option instead" + echo " Then restart the container." + echo "================================================================" + echo "" + exit 1 + fi + chmod 700 "$POSTGRES_DIR" + # Write sentinel LAST — if chown was interrupted, the sentinel + # won't exist and next startup will re-run the full chown. + write_ownership_sentinel + echo "Ownership migration complete." + fi + + # --- 2. Authentication guarantee (unconditional) --- + # Always rewrite pg_hba.conf to the known-good state. This replaces + # any auth method (peer, ident, md5, scram) left by previous images + # or initdb defaults. Eliminates the class of bugs where the OS user + # name doesn't match any PG role under peer/ident auth. + configure_pg_network "${POSTGRES_DIR}" + fi + # Only run upgrade if current version is set and not the target if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." @@ -56,6 +163,51 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then PG_INSTALLED_BY_SCRIPT=1 fi + # Prepare the old cluster for pg_upgrade: + # 1. Promote $POSTGRES_USER to superuser (needed for post-upgrade ops) + # 2. Detect the bootstrap superuser (install user) — pg_upgrade + # requires -U to match this role exactly. + # The old cluster's install user is "postgres" (pre-PUID images) + # or $POSTGRES_USER (post-PUID images, future upgrades). + _pg_tmp_port=5499 + echo "Preparing old cluster for upgrade..." + su - "$POSTGRES_USER" -c "$OLD_BINDIR/pg_ctl -D $POSTGRES_DIR start -w -o '-c port=$_pg_tmp_port'" + _promoted=false + for _role in "postgres" "$POSTGRES_USER"; do + if su - "$POSTGRES_USER" -c "psql -U $_role -d template1 -p $_pg_tmp_port -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then + if su - "$POSTGRES_USER" -c "psql -U $_role -d template1 -p $_pg_tmp_port -v ON_ERROR_STOP=1" </dev/null | tr -d '[:space:]') + if [ -z "$_install_user" ]; then + _install_user="postgres" + fi + + su - "$POSTGRES_USER" -c "$OLD_BINDIR/pg_ctl -D $POSTGRES_DIR stop -w" + if [ "$_promoted" != true ]; then + echo "❌ Failed to prepare old cluster for upgrade." + echo " Could not promote '$POSTGRES_USER' to superuser in PG $CURRENT_VERSION." + exit 1 + fi + echo "Old cluster install user: $_install_user" + # Prepare new data directory NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" @@ -66,20 +218,26 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then fi mkdir -p "$NEW_POSTGRES_DIR" - chown -R postgres:postgres "$NEW_POSTGRES_DIR" + chown -R $PUID:$PGID "$NEW_POSTGRES_DIR" chmod 700 "$NEW_POSTGRES_DIR" - # Initialize new data directory + # Initialize new data directory with the same install user as the old + # cluster. pg_upgrade requires the -U user to match both clusters. echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." - su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR" + su - "$POSTGRES_USER" -c "$NEW_BINDIR/initdb -U $_install_user -D $NEW_POSTGRES_DIR" echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." - # Run pg_upgrade - su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + su - "$POSTGRES_USER" -c "$NEW_BINDIR/pg_upgrade -U $_install_user -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" # Move old data directory for backup, move new into place mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" + # Apply standard connection configuration to the upgraded data directory. + configure_pg_network "${POSTGRES_DIR}" + + # Record ownership sentinel for the newly upgraded data directory. + write_ownership_sentinel + echo "Upgrade complete. Old data directory backed up." # Uninstall PostgreSQL if we installed it just for upgrade @@ -90,61 +248,154 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then fi fi - # Initialize PostgreSQL database - if [ -z "$(ls -A $POSTGRES_DIR)" ]; then + # Initialize PostgreSQL data directory (fresh install only). + # Only runs initdb + configure_pg_network here. Database creation, + # role setup, and password configuration are handled by the + # unconditional guarantees (promote_app_role, ensure_app_database) + # after PostgreSQL starts in entrypoint.sh. + if [ -z "$(ls -A "$POSTGRES_DIR")" ]; then echo "Initializing PostgreSQL database..." - mkdir -p $POSTGRES_DIR - chown -R postgres:postgres $POSTGRES_DIR - chmod 700 $POSTGRES_DIR + mkdir -p "$POSTGRES_DIR" + chown -R "$PUID:$PGID" "$POSTGRES_DIR" + chmod 700 "$POSTGRES_DIR" - # Initialize PostgreSQL - su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" - # Configure PostgreSQL - echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf" - echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf" + # Initialize PostgreSQL as the application user. + # The superuser role is automatically named $POSTGRES_USER. + su - "$POSTGRES_USER" -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" - # Start PostgreSQL - echo "Starting Postgres..." - su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" - # Wait for PostgreSQL to be ready - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo "Waiting for PostgreSQL to be ready..." - sleep 1 - done + # Configure authentication and network access. + configure_pg_network "${POSTGRES_DIR}" - postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - - # Setup database if needed - if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then - # Create PostgreSQL database - echo "Creating PostgreSQL database..." - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges - echo "Creating PostgreSQL user..." - su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" </dev/null | tr -d '[:space:]') + if [ "$_super" = "t" ]; then + CONNECT_ROLE="$try_role" + CONNECT_DB="$try_db" + break 2 + fi + done + done + + if [ -z "$CONNECT_ROLE" ]; then + echo "❌ Role setup failed: no connectable superuser role found." + echo " To recover manually:" + echo " su - $POSTGRES_USER -c \"psql -d template1 -p $POSTGRES_PORT\"" + echo " CREATE ROLE $POSTGRES_USER WITH SUPERUSER LOGIN PASSWORD '';" + exit 1 + fi + + # Escape single quotes for safe SQL interpolation + local _sql_pw="${POSTGRES_PASSWORD//\'/\'\'}" + + if ! su - $POSTGRES_USER -c "psql -U $CONNECT_ROLE -d $CONNECT_DB -p ${POSTGRES_PORT} -v ON_ERROR_STOP=1" <';" + exit 1 + fi + + echo "✅ Application role configured." +} + +# ========================================================================= +# 4. Database guarantee (unconditional — runs after role setup) +# +# Ensures the application database ($POSTGRES_DB) exists. Handles +# incomplete data from interrupted previous initializations where +# PG_VERSION exists but the application database was never created. +# ========================================================================= +ensure_app_database() { + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + return 0 + fi + + # Connect to template1 (always exists) to check pg_database catalog. + if su - $POSTGRES_USER -c "psql -d template1 -p ${POSTGRES_PORT} -tAc \ + \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" 2>/dev/null | grep -q 1; then + return 0 + fi + + echo "Application database '$POSTGRES_DB' not found — creating..." + if ! su - $POSTGRES_USER -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}" 2>/dev/null; then + # Might already exist if the check failed for a transient reason. + if su - $POSTGRES_USER -c "psql -d template1 -p ${POSTGRES_PORT} -tAc \ + \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" 2>/dev/null | grep -q 1; then + return 0 + fi + echo "❌ Failed to create database '$POSTGRES_DB'" + exit 1 + fi + echo "✅ Database '$POSTGRES_DB' created." +} + ensure_utf8_encoding() { # Check encoding of existing database # Supports both internal (Unix socket) and external (TCP) PostgreSQL @@ -154,8 +405,8 @@ ensure_utf8_encoding() { # External database: use TCP connection with password CURRENT_ENCODING=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();" 2>/dev/null | tr -d ' ') else - # Internal database: use Unix socket as postgres user - CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ') + # Internal database: use Unix socket as application user + CURRENT_ENCODING=$(su - $POSTGRES_USER -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ') fi if [ "$CURRENT_ENCODING" != "UTF8" ]; then @@ -173,15 +424,15 @@ ensure_utf8_encoding() { # Restore data PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; } else - # Internal database: use Unix socket as postgres user + # Internal database: use Unix socket as application user # Dump database (include permissions and ownership) - su - postgres -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + su - $POSTGRES_USER -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } # Drop and recreate database with UTF8 encoding using template0 - su - postgres -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; } + su - $POSTGRES_USER -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; } # Recreate database with UTF8 encoding and correct owner - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; } + su - $POSTGRES_USER -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; } # Restore data - cat "$DUMP_FILE" | su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; } + cat "$DUMP_FILE" | su - $POSTGRES_USER -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; } fi rm -f "$DUMP_FILE" @@ -249,5 +500,3 @@ check_external_postgres_version() { return 0 } - - diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index b4524bbc..7c3baa48 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -65,11 +65,9 @@ if [ "$(id -u)" = "0" ]; then fi done - # Database permissions - if [ -d /data/db ] && [ "$(stat -c '%u' /data/db)" != "$(id -u postgres)" ]; then - echo "Fixing ownership for /data/db" - chown -R postgres:postgres /data/db - fi + # /data/db ownership is handled by 02-postgres.sh (sentinel-based reconciliation). + # No secondary check needed here — duplicating it could chown without updating + # the sentinel, creating inconsistent state. # Fix /data directory ownership (non-recursive) if [ -d "/data" ] && [ "$(stat -c '%u:%g' /data)" != "$PUID:$PGID" ]; then diff --git a/docker/tests/test-puid-pgid.sh b/docker/tests/test-puid-pgid.sh new file mode 100644 index 00000000..4f460450 --- /dev/null +++ b/docker/tests/test-puid-pgid.sh @@ -0,0 +1,1517 @@ +#!/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 +# 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:-}" + 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:-}" + 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:-}')" + 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:-})" + 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:-}" +} + +# 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:-}" + 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:-}" + 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:-}" + 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:-}" + 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:-}" + 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