From 313268a7ae61fb3847cd02f1da2fc353b359e0c8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 11:17:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20db-init=20one-shot=20service=20?= =?UTF-8?q?=E2=80=94=20paste-and-go=20bootstrap=20for=20the=20runtime=20ro?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role split previously required a one-time manual script run on existing volumes (initdb.d only fires on fresh ones). This folds the bootstrap into the compose file the way split-account deployments standardly do it: a one-shot db-init service runs z_rls_role.sh (idempotent, network mode via PGHOST=db + PGPASSWORD from DBPASS) on every 'compose up', and the app gates on db-init: condition: service_completed_successfully. - Zero manual steps for fresh AND existing deployments: update compose, add DBSRVPASS to .env, 'compose pull && up -d'. - Security posture improves: the superuser password is now held ONLY by the init service — the app container connects exclusively as the non-superuser runtime role. - z_rls_role.sh gains dual connection modes (local socket for docker exec, network for the init service) with the DBPASS requirement enforced only in network mode. - Verified on the dev stack: init runs and exits clean, app gates on it and comes up healthy, second 'up' re-converges idempotently, invariant test passes through the new dependency chain. The initdb.d mount stays as-is (harmless double coverage on fresh volumes; both paths are idempotent). --- .env.example | 5 ++--- database/schema/z_rls_role.sh | 26 ++++++++++++++++---------- docker-compose.yml | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index be27dd59..b777cd22 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,8 @@ DBPASS=your-secure-database-password-here # Runtime database role password (bookhoard_srv — the non-superuser # account the app connects as; subject to RLS). Required for the app to -# start. On a fresh volume it is consumed by database/schema/z_rls_role.sh -# during first initialization; on an existing volume, run that script once -# (see its header) BEFORE starting the app with DATABASE_USER=bookhoard_srv. +# start. Consumed by the one-shot db-init service on every startup +# (idempotent — creates/refreshes the role and its privileges). # Generate with: openssl rand -hex 16 DBSRVPASS=your-secure-runtime-role-password-here diff --git a/database/schema/z_rls_role.sh b/database/schema/z_rls_role.sh index 815a0628..a98b97a2 100755 --- a/database/schema/z_rls_role.sh +++ b/database/schema/z_rls_role.sh @@ -9,15 +9,11 @@ # remains the cluster/database bootstrap account; the app never connects # as it. # -# Runs AUTOMATICALLY on fresh volumes: docker-entrypoint-initdb.d executes -# initdb.d entries in alphabetical order — "z_rls_role.sh" sorts AFTER -# "schema.sql" (digits/uppercase sort before lowercase 's'), so the tables -# exist before ownership transfers. It runs as the postgres superuser with -# the db container's env (DBSRVPASS comes from .env via compose env_file). -# -# EXISTING volumes (initdb.d never re-runs): apply manually ONCE, as the -# superuser, BEFORE switching the app's DATABASE_USER: -# docker exec -i bookhoard_db bash -s < database/schema/z_rls_role.sh +# Runs on EVERY `docker compose up` via the one-shot `db-init` service +# (idempotent — the app's depends_on gates on its success), AND on +# fresh volumes via docker-entrypoint-initdb.d ('z' sorts after +# 'schema.sql'). Manual equivalent for existing volumes: +# docker exec -i -e DBSRVPASS=... bookhoard_db bash -s < database/schema/z_rls_role.sh # # IDEMPOTENT: safe to re-run (role creation is guarded; ownership ALTERs # and the password set are idempotent). @@ -30,7 +26,17 @@ set -euo pipefail : "${POSTGRES_USER:?POSTGRES_USER must be set (the image sets it)}" : "${POSTGRES_DB:?POSTGRES_DB must be set (the image sets it)}" -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ +# Connection mode: local socket (docker exec into the db container) or +# network (the db-init compose service, which sets PGHOST=db and needs +# the bootstrap password for scram auth). +PSQL_OPTS=(-v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB") +if [ -n "${PGHOST:-}" ]; then + : "${DBPASS:?DBPASS is required when connecting over the network (PGHOST set)}" + PSQL_OPTS+=(-h "$PGHOST") + export PGPASSWORD="$DBPASS" +fi + +psql "${PSQL_OPTS[@]}" \ -v role_pass="$DBSRVPASS" <<'EOSQL' \echo '== RLS role bootstrap: creating/refreshing bookhoard_srv ==' diff --git a/docker-compose.yml b/docker-compose.yml index 4d14fdc7..64d72b37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,27 @@ services: env_file: - .env + # One-shot bootstrap: creates the bookhoard_srv runtime role and + # transfers object ownership to it (RLS prerequisite). Idempotent — + # safe on every startup; the app waits for its success. Passwords + # accept inline values or .env, like the rest of this file. + db-init: + image: postgres:15-alpine + container_name: bookhoard_db_init + environment: + POSTGRES_USER: postgres + POSTGRES_DB: bookhoard + PGHOST: db + DBPASS: ${DBPASS} + DBSRVPASS: ${DBSRVPASS} + volumes: + - ./database/schema/z_rls_role.sh:/z_rls_role.sh:ro + command: bash /z_rls_role.sh + depends_on: + db: + condition: service_healthy + restart: "no" + # Bookhoard Application # In production this image is pulled from the Gitea container registry. # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest"). @@ -78,6 +99,8 @@ services: depends_on: db: condition: service_healthy + db-init: + condition: service_completed_successfully volumes: - ./uploads:/app/uploads - bookhoard_conversion_cache:/app/cache/kepub