From 45f2bbb1981f828693e9f44ad3d537bc95ab45e3 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Fri, 23 Oct 2026 22:27:17 +0000 Subject: [PATCH] db-refresh-dev: own dev DB as personal_dev, no prod grants; PG_CONTAINER mode --- scripts/db-refresh-dev.sh | 76 ++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/scripts/db-refresh-dev.sh b/scripts/db-refresh-dev.sh index bcef66a..95f0ed5 100755 --- a/scripts/db-refresh-dev.sh +++ b/scripts/db-refresh-dev.sh @@ -1,37 +1,58 @@ #!/usr/bin/env bash -# Refresh the local development database from a fresh production dump. +# Refresh the development database (personal_dev) from a fresh production dump. # # Prod is the source of truth for data; dev is a disposable copy of it. # # Required env (put them in .env.dev-refresh or export them; never commit real values): # PROD_DB_URL read access to prod, e.g. postgresql://personal:...@127.0.0.1:5440/personal -# DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5432/postgres +# DEV_ADMIN_URL superuser on the dev cluster, maintenance DB, e.g. postgresql://postgres:...@127.0.0.1:5440/postgres +# (not needed with PG_CONTAINER, which uses the container's local superuser) # Optional: # DEV_DB_NAME target database (default personal_dev) +# DEV_OWNER role that owns every object in the dev DB (default personal_dev). It is the +# role the dev site logs in as, so it can also run migrations there. It must not +# be a member of any prod role; prod's CONNECT is not granted to it. # PG_DOCKER=1 use the postgres:17-alpine image instead of local pg_dump/pg_restore +# PG_CONTAINER name of the Postgres container (e.g. postgres-db-1): run every tool inside it +# via `docker exec` as the local superuser. URLs are then as seen from inside +# the container, e.g. PROD_DB_URL=postgresql://personal:...@127.0.0.1:5432/personal +# DOCKER docker command (default "docker"; e.g. "sudo -n docker") # -# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME -> create -# missing roles (NOLOGIN) -> restore with ownership/grants intact -> clear sessions -# and verification tokens so prod logins can't be reused in dev. It never writes to prod. +# What it does: dump prod (custom format) -> drop + recreate DEV_DB_NAME owned by DEV_OWNER -> +# restore without prod ownership/grants, every object owned by DEV_OWNER -> clear sessions and +# verification tokens so prod logins can't be reused in dev. It never writes to prod. set -euo pipefail cd "$(dirname "$0")/.." if [[ -f .env.dev-refresh ]]; then set -a; . ./.env.dev-refresh; set +a; fi : "${PROD_DB_URL:?set PROD_DB_URL (source, read-only use)}" -: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}" DEV_DB_NAME="${DEV_DB_NAME:-personal_dev}" - -if [[ ! "$DEV_DB_NAME" =~ ^[a-z_][a-z0-9_]*$ ]]; then - echo "DEV_DB_NAME must be a plain lowercase identifier" >&2 - exit 1 +DEV_OWNER="${DEV_OWNER:-personal_dev}" +DOCKER="${DOCKER:-docker}" +if [[ -n "${PG_CONTAINER:-}" ]]; then + DEV_ADMIN_URL="${DEV_ADMIN_URL:-postgresql://postgres@localhost/postgres}" fi +: "${DEV_ADMIN_URL:?set DEV_ADMIN_URL (superuser URL to the dev cluster maintenance DB)}" + +for ident in "$DEV_DB_NAME" "$DEV_OWNER"; do + if [[ ! "$ident" =~ ^[a-z_][a-z0-9_]*$ ]]; then + echo "DEV_DB_NAME / DEV_OWNER must be plain lowercase identifiers" >&2 + exit 1 + fi +done case "$DEV_DB_NAME" in personal | personal_prod | postgres | template0 | template1) echo "Refusing to overwrite '$DEV_DB_NAME'" >&2 exit 1 ;; esac +case "$DEV_OWNER" in +personal | personal_app | personal_ddl | personal_ro | postgres) + echo "Refusing: DEV_OWNER '$DEV_OWNER' is a prod/superuser role" >&2 + exit 1 + ;; +esac # host:port/db of a URL (no credentials), for the "same database" guard. endpoint() { bun -e 'const u=new URL(process.argv[1]); console.log(`${u.hostname}:${u.port||5432}${u.pathname}`)' "$1"; } @@ -44,8 +65,12 @@ if [[ "$(endpoint "$dev_url")" == "$prod_endpoint" ]]; then exit 1 fi -if [[ "${PG_DOCKER:-0}" == 1 ]]; then - pg() { docker run --rm -i --network host -v "$work:$work" postgres:17-alpine "$@"; } +# Dump goes to stdout and restore reads stdin, so the same commands work locally, +# in a throwaway postgres image, or inside the Postgres container. +if [[ -n "${PG_CONTAINER:-}" ]]; then + pg() { $DOCKER exec -i -u postgres "$PG_CONTAINER" "$@"; } +elif [[ "${PG_DOCKER:-0}" == 1 ]]; then + pg() { $DOCKER run --rm -i --network host postgres:17-alpine "$@"; } else pg() { "$@"; } fi @@ -55,27 +80,30 @@ trap 'rm -rf "$work"' EXIT dump="$work/prod.dump" echo "Dumping prod ($prod_endpoint)..." -pg pg_dump --format=custom --no-password --file="$dump" "$PROD_DB_URL" +pg pg_dump --format=custom --no-password "$PROD_DB_URL" >"$dump" +[[ -s "$dump" ]] || { echo "Empty dump; aborting before touching $DEV_DB_NAME" >&2; exit 1; } +pg pg_restore --list <"$dump" >/dev/null -echo "Recreating $DEV_DB_NAME..." +echo "Recreating $DEV_DB_NAME (owner $DEV_OWNER)..." pg psql -v ON_ERROR_STOP=1 -q "$DEV_ADMIN_URL" < pg_backend_pid(); +SELECT count(pg_terminate_backend(pid)) AS terminated_sessions FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid(); DROP DATABASE IF EXISTS "$DEV_DB_NAME"; DO \$\$ -DECLARE r text; BEGIN - FOREACH r IN ARRAY ARRAY['personal','personal_app','personal_ddl','personal_ro'] LOOP - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN - EXECUTE format('CREATE ROLE %I NOLOGIN', r); - RAISE NOTICE 'created role % (NOLOGIN; give it a password + LOGIN for dev use)', r; - END IF; - END LOOP; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DEV_OWNER') THEN + EXECUTE format('CREATE ROLE %I NOLOGIN', '$DEV_OWNER'); + RAISE NOTICE 'created role $DEV_OWNER (NOLOGIN; give it a password + LOGIN for dev use)'; + END IF; END \$\$; -CREATE DATABASE "$DEV_DB_NAME" OWNER personal; +CREATE DATABASE "$DEV_DB_NAME" OWNER "$DEV_OWNER"; +REVOKE CONNECT, TEMPORARY ON DATABASE "$DEV_DB_NAME" FROM PUBLIC; SQL echo "Restoring into $DEV_DB_NAME..." -pg pg_restore --no-password --exit-on-error --dbname="$dev_url" "$dump" +# --no-owner/--no-privileges + --role: everything ends up owned by DEV_OWNER and none of +# prod's grants (personal_app, personal_ro, ...) are copied into dev. +pg pg_restore --no-password --exit-on-error --no-owner --no-privileges --role="$DEV_OWNER" \ + --dbname="$dev_url" <"$dump" echo "Clearing sessions and verification tokens..." pg psql -v ON_ERROR_STOP=1 -q "$dev_url" -c 'TRUNCATE auth.session, auth.verification'