From 27c615d6cbd16e0fdc815b0760ef3b8b4d9ea8b6 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 21 Jul 2026 04:35:03 +0300 Subject: [PATCH] Support host PostgreSQL through Unix sockets --- .env.example | 4 + README.md | 22 +++ compose.external-db-socket.yaml | 16 ++ compose.yaml | 1 + config/runtime.exs | 34 +++- docs/operations.md | 53 +++++++ scripts/compose.sh | 5 + scripts/init-production-env.sh | 15 ++ scripts/provision-host-postgres.sh | 242 +++++++++++++++++++++++++++++ scripts/quality.sh | 56 +++++++ scripts/validate-production-env.sh | 13 ++ 11 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 compose.external-db-socket.yaml create mode 100755 scripts/provision-host-postgres.sh diff --git a/.env.example b/.env.example index 70ba7f8..a292f69 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,10 @@ POSTGRES_DB=who_need_help POSTGRES_USER=postgres POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help +# Optional absolute host directory containing PostgreSQL Unix sockets. When it +# is set in external mode, Compose mounts it read-only and Ecto uses it instead +# of the hostname in DATABASE_URL. Leave empty for container or remote TCP DBs. +DATABASE_SOCKET_DIR= WEB_POOL_SIZE=4 WORKER_POOL_SIZE=2 diff --git a/README.md b/README.md index 578feaf..fb552fb 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,28 @@ reports whether the observed connection uses TLS without printing the URL or credentials. Provider-specific CA/network requirements still have to be configured from that provider's verified documentation. +For PostgreSQL installed on the same Linux host, keep its TCP listener private +and connect through its Unix socket. The root-only bootstrap refuses existing +project roles/databases, backs up `pg_hba.conf`, adds two exact SCRAM rules, +creates independent production/staging roles and empty databases, preloads +`citext` and PostGIS, verifies both logins, and writes mode-`0600` initializer +fragments without printing their passwords: + +```bash +sudo ./scripts/provision-host-postgres.sh "$USER" +set -a +. "$HOME/.config/who_need_help/database-production.env" +set +a +./scripts/init-production-env.sh whoneedhelp.com .env.production +unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \ + PRODUCTION_DATABASE_SOCKET_DIR +``` + +Use `database-staging.env` for the isolated staging environment. Compose mounts +only the configured socket directory read-only; Ecto migrations remain the +source of application schema. Inspect the exact host PostgreSQL state and the +script's documented impact before the sudo invocation. + `compose.production.yaml` leaves local Mailpit stopped. Validation deliberately fails while the relay still points to Mailpit or a template marker remains. It does not claim to test DNS, certificates, actual mail delivery, the diff --git a/compose.external-db-socket.yaml b/compose.external-db-socket.yaml new file mode 100644 index 0000000..9df8cfb --- /dev/null +++ b/compose.external-db-socket.yaml @@ -0,0 +1,16 @@ +services: + migrate: + volumes: + - ${DATABASE_SOCKET_DIR:?Set DATABASE_SOCKET_DIR for a host PostgreSQL Unix socket}:${DATABASE_SOCKET_DIR}:ro + + app: + volumes: + - ${DATABASE_SOCKET_DIR:?Set DATABASE_SOCKET_DIR for a host PostgreSQL Unix socket}:${DATABASE_SOCKET_DIR}:ro + + web: + volumes: + - ${DATABASE_SOCKET_DIR:?Set DATABASE_SOCKET_DIR for a host PostgreSQL Unix socket}:${DATABASE_SOCKET_DIR}:ro + + worker: + volumes: + - ${DATABASE_SOCKET_DIR:?Set DATABASE_SOCKET_DIR for a host PostgreSQL Unix socket}:${DATABASE_SOCKET_DIR}:ro diff --git a/compose.yaml b/compose.yaml index e2cd864..7da79e9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,6 +3,7 @@ name: who_need_help x-app-environment: &app-environment APP_ROLE: web DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} + DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-} SECRET_KEY_BASE: ${SECRET_KEY_BASE:?Set SECRET_KEY_BASE in .env} HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env} RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env} diff --git a/config/runtime.exs b/config/runtime.exs index 593b0cb..9b34cd4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -339,13 +339,33 @@ if config_env() == :prod do maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] - config :who_need_help, WhoNeedHelp.Repo, - # ssl: true, - url: database_url, - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), - # For machines with several cores, consider starting multiple pools of `pool_size` - # pool_count: 4, - socket_options: maybe_ipv6 + database_socket_dir = + case System.get_env("DATABASE_SOCKET_DIR") do + value when value in [nil, ""] -> + nil + + "/" <> _rest = value -> + value + + _other -> + raise "DATABASE_SOCKET_DIR must be an absolute path when configured." + end + + repo_options = + [ + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + ] + |> then(fn options -> + if database_socket_dir do + Keyword.put(options, :socket_dir, database_socket_dir) + else + options + end + end) + + config :who_need_help, WhoNeedHelp.Repo, repo_options # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you diff --git a/docs/operations.md b/docs/operations.md index 5d2295b..9eb460e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -161,6 +161,59 @@ run-scoped account. ### External PostgreSQL/PostGIS +#### PostgreSQL on the same Linux host + +The server-local mode uses the PostgreSQL Unix socket instead of exposing the +database on a Docker-reachable TCP address. Ecto/Postgrex receive +`DATABASE_SOCKET_DIR`; `scripts/compose.sh` then adds +`compose.external-db-socket.yaml` and mounts that exact directory read-only +into the active application and migration services. An empty setting preserves +the normal remote-provider TCP behavior. + +After read-only inspection confirms the intended PostgreSQL 18 cluster, +installed PostGIS package, socket directory, and absence of the project-scoped +roles/databases, run the root-only provisioner: + +```bash +sudo ./scripts/provision-host-postgres.sh "$USER" +``` + +Its exact mutation scope is: + +- prepend two database-and-role-specific `local ... scram-sha-256` rules to the + active `pg_hba.conf`, retaining a mode-`0600` copy under + `/var/backups/who_need_help/`, and reload that cluster; +- create login roles `wnh_production` and `wnh_staging` without superuser, + database-creation, role-creation, replication, or row-security bypass rights; +- create empty owner databases `who_need_help_production` and + `who_need_help_staging`, revoke public connect, and preload `citext` and + PostGIS so the non-superuser Ecto migrations can run; +- verify both credentials through the observed Unix socket and write separate + mode-`0600` initializer fragments under + `~/.config/who_need_help/` without printing passwords. + +The command refuses to overwrite credential fragments, refuses any matching +pre-existing role/database or managed HBA marker, validates the candidate HBA +rules before reload, and restores its HBA backup plus removes only objects it +created if provisioning fails. It does not inspect or migrate application +tables; the release migration runner remains authoritative for schema. + +Generate staging or production after loading only the matching trusted +fragment: + +```bash +set -a +. "$HOME/.config/who_need_help/database-production.env" +set +a +./scripts/init-production-env.sh whoneedhelp.com .env.production +unset PRODUCTION_DATABASE_MODE PRODUCTION_DATABASE_URL \ + PRODUCTION_DATABASE_SOCKET_DIR +``` + +For staging, load `database-staging.env` and pass the staging domain, project, +upstream alias, and HTTP port described above. Do not load both fragments into +one shell. + Provision the database and role first, then generate the environment without placing its credentials on a command line that is retained in shell history: diff --git a/scripts/compose.sh b/scripts/compose.sh index 28bb60c..3efed3d 100755 --- a/scripts/compose.sh +++ b/scripts/compose.sh @@ -56,6 +56,7 @@ esac database_url=$(read_env_value DATABASE_URL 2>/dev/null || true) : "${database_url:?Set DATABASE_URL in $env_file}" +database_socket_dir=$(read_env_value DATABASE_SOCKET_DIR 2>/dev/null || true) if [[ "$database_mode" == container ]]; then postgres_db=$(read_env_value POSTGRES_DB 2>/dev/null || true) @@ -86,6 +87,10 @@ compose=( if [[ "$database_mode" == external ]]; then compose+=(--file "$ROOT/compose.external-db.yaml") + + if [[ -n "$database_socket_dir" ]]; then + compose+=(--file "$ROOT/compose.external-db-socket.yaml") + fi fi if [[ "$app_topology" == compact ]]; then diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 345fdd1..fdb2938 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -83,6 +83,19 @@ case "$database_mode" in ;; esac +database_socket_dir=${PRODUCTION_DATABASE_SOCKET_DIR:-} +if [ -n "$database_socket_dir" ]; then + case "$database_socket_dir" in + /*) ;; + *) echo "PRODUCTION_DATABASE_SOCKET_DIR must be an absolute path." >&2; exit 1 ;; + esac + + if [ "$database_mode" != external ]; then + echo "PRODUCTION_DATABASE_SOCKET_DIR is only valid when PRODUCTION_DATABASE_MODE=external." >&2 + exit 1 + fi +fi + case "$app_topology" in compact | split) ;; *) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;; @@ -129,6 +142,7 @@ DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \ TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \ POSTGRES_PASSWORD_VALUE=$postgres_password \ DATABASE_URL_VALUE=$database_url \ +DATABASE_SOCKET_DIR_VALUE=$database_socket_dir \ SECRET_KEY_BASE_VALUE=$secret_key_base \ HANDOVER_SECRET_VALUE=$handover_secret \ RELEASE_COOKIE_VALUE=$release_cookie \ @@ -166,6 +180,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"] replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"] replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"] + replacement["DATABASE_SOCKET_DIR"] = ENVIRON["DATABASE_SOCKET_DIR_VALUE"] replacement["SECRET_KEY_BASE"] = ENVIRON["SECRET_KEY_BASE_VALUE"] replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"] replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"] diff --git a/scripts/provision-host-postgres.sh b/scripts/provision-host-postgres.sh new file mode 100755 index 0000000..5167d2a --- /dev/null +++ b/scripts/provision-host-postgres.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" != 0 ]]; then + echo "Run this script as root through sudo on the PostgreSQL host." >&2 + exit 1 +fi + +operator=${1:-${SUDO_USER:-}} +if [[ -z "$operator" || "$operator" == root ]]; then + echo "Usage: sudo $0 OPERATOR_USER [OUTPUT_DIRECTORY]" >&2 + exit 1 +fi + +operator_entry=$(getent passwd "$operator") || { + echo "Operator user does not exist: $operator" >&2 + exit 1 +} +operator_home=$(cut -d: -f6 <<<"$operator_entry") +operator_group=$(id -gn "$operator") +output_dir=${2:-"$operator_home/.config/who_need_help"} + +postgres_version=${POSTGRES_CLUSTER_VERSION:-18} +postgres_cluster=${POSTGRES_CLUSTER_NAME:-main} +socket_dir=${POSTGRES_SOCKET_DIR:-/var/run/postgresql} + +production_role=wnh_production +production_database=who_need_help_production +staging_role=wnh_staging +staging_database=who_need_help_staging +hba_marker="# BEGIN Who Need Help managed local socket authentication" + +for command in awk cat chgrp chmod chown cut date getent grep id install mktemp \ + openssl pg_ctlcluster pg_lsclusters psql rm runuser sed stat tr; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 1 + } +done + +cluster_status=$( + pg_lsclusters --no-header | + awk -v version="$postgres_version" -v cluster="$postgres_cluster" \ + '$1 == version && $2 == cluster {print $4}' +) +if [[ "$cluster_status" != online ]]; then + echo "PostgreSQL cluster $postgres_version/$postgres_cluster is not online." >&2 + exit 1 +fi + +postgres_psql=( + runuser -u postgres -- + psql --no-psqlrc --set ON_ERROR_STOP=1 --quiet --tuples-only --no-align +) + +hba_file=$("${postgres_psql[@]}" --dbname postgres --command 'SHOW hba_file') +configured_socket_dirs=$( + "${postgres_psql[@]}" --dbname postgres --command 'SHOW unix_socket_directories' +) +postgres_port=$("${postgres_psql[@]}" --dbname postgres --command 'SHOW port') + +[[ "$hba_file" == /* && -f "$hba_file" ]] || { + echo "PostgreSQL reported an unusable hba_file path." >&2 + exit 1 +} +[[ "$socket_dir" == /* && -d "$socket_dir" ]] || { + echo "PostgreSQL socket directory is unavailable: $socket_dir" >&2 + exit 1 +} +if ! tr ',' '\n' <<<"$configured_socket_dirs" | + sed -e "s/^[[:space:]']*//" -e "s/[[:space:]']*$//" | + grep -Fx "$socket_dir" >/dev/null; then + echo "POSTGRES_SOCKET_DIR is not listed in unix_socket_directories." >&2 + exit 1 +fi + +socket_path="$socket_dir/.s.PGSQL.$postgres_port" +[[ -S "$socket_path" ]] || { + echo "PostgreSQL Unix socket is unavailable: $socket_path" >&2 + exit 1 +} +socket_mode=$(stat -c '%a' "$socket_path") +case "${socket_mode: -1}" in + 6 | 7) ;; + *) + echo "PostgreSQL socket is not writable by the unprivileged container user." >&2 + exit 1 + ;; +esac + +if grep -F "$hba_marker" "$hba_file" >/dev/null; then + echo "Who Need Help HBA rules already exist; refusing an ambiguous reprovision." >&2 + exit 1 +fi + +existing_objects=$( + "${postgres_psql[@]}" --dbname postgres --command " + SELECT 'role:' || rolname FROM pg_roles + WHERE rolname IN ('$production_role', '$staging_role') + UNION ALL + SELECT 'database:' || datname FROM pg_database + WHERE datname IN ('$production_database', '$staging_database') + ORDER BY 1; + " +) +if [[ -n "$existing_objects" ]]; then + echo "Project-scoped PostgreSQL roles or databases already exist:" >&2 + while IFS= read -r object; do + printf ' %s\n' "$object" >&2 + done <<<"$existing_objects" + echo "Inspect them before deciding whether to reuse, rotate, or remove them." >&2 + exit 1 +fi + +production_fragment="$output_dir/database-production.env" +staging_fragment="$output_dir/database-staging.env" +if [[ -e "$production_fragment" || -e "$staging_fragment" ]]; then + echo "Database credential fragments already exist; refusing to overwrite them." >&2 + exit 1 +fi + +umask 077 +production_password=$(openssl rand -hex 32) +staging_password=$(openssl rand -hex 32) +work_dir=$(mktemp -d) +chgrp postgres "$work_dir" +chmod 750 "$work_dir" +hba_candidate="$work_dir/pg_hba.conf" +hba_backup="$work_dir/pg_hba.conf.original" +sql_file="$work_dir/provision.sql" +backup_dir=/var/backups/who_need_help +backup_file="$backup_dir/pg_hba.conf.before-who-need-help-$(date -u +%Y%m%dT%H%M%SZ)" +provision_started=false +provision_finished=false + +cleanup() { + status=$? + + if [[ "$status" != 0 && "$provision_started" == true && "$provision_finished" == false ]]; then + "${postgres_psql[@]}" --dbname postgres --command \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('$production_database', '$staging_database') AND pid <> pg_backend_pid();" \ + >/dev/null 2>&1 || true + "${postgres_psql[@]}" --dbname postgres --command \ + "DROP DATABASE IF EXISTS $staging_database;" >/dev/null 2>&1 || true + "${postgres_psql[@]}" --dbname postgres --command \ + "DROP DATABASE IF EXISTS $production_database;" >/dev/null 2>&1 || true + "${postgres_psql[@]}" --dbname postgres --command \ + "DROP ROLE IF EXISTS $staging_role; DROP ROLE IF EXISTS $production_role;" \ + >/dev/null 2>&1 || true + + if [[ -f "$hba_backup" ]]; then + install -m 640 -o postgres -g postgres "$hba_backup" "$hba_file" || true + pg_ctlcluster "$postgres_version" "$postgres_cluster" reload || true + fi + + rm -f "$production_fragment" "$staging_fragment" + fi + + rm -rf "$work_dir" + unset production_password staging_password + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +install -m 600 -o root -g root "$hba_file" "$hba_backup" +{ + printf '%s\n' "$hba_marker" + printf 'local %s %s scram-sha-256\n' "$production_database" "$production_role" + printf 'local %s %s scram-sha-256\n' "$staging_database" "$staging_role" + printf '%s\n' '# END Who Need Help managed local socket authentication' + cat "$hba_backup" +} >"$hba_candidate" + +install -d -m 700 -o root -g root "$backup_dir" +install -m 600 -o root -g root "$hba_backup" "$backup_file" +install -m 640 -o postgres -g postgres "$hba_candidate" "$hba_file" +provision_started=true + +hba_errors=$( + "${postgres_psql[@]}" --dbname postgres --command \ + "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL;" +) +if [[ "$hba_errors" != 0 ]]; then + echo "PostgreSQL rejected the candidate pg_hba.conf; restoring the backup." >&2 + exit 1 +fi +pg_ctlcluster "$postgres_version" "$postgres_cluster" reload + +cat >"$sql_file" </dev/null +PGPASSWORD="$staging_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \ + --host "$socket_dir" --username "$staging_role" \ + --dbname "$staging_database" --quiet --tuples-only --no-align \ + --command 'SELECT current_user, current_database(), PostGIS_Version()' >/dev/null + +install -d -m 700 -o "$operator" -g "$operator_group" "$output_dir" +production_tmp="$work_dir/database-production.env" +staging_tmp="$work_dir/database-staging.env" +printf '%s\n' \ + 'PRODUCTION_DATABASE_MODE=external' \ + "PRODUCTION_DATABASE_URL=ecto://$production_role:$production_password@localhost/$production_database" \ + "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$production_tmp" +printf '%s\n' \ + 'PRODUCTION_DATABASE_MODE=external' \ + "PRODUCTION_DATABASE_URL=ecto://$staging_role:$staging_password@localhost/$staging_database" \ + "PRODUCTION_DATABASE_SOCKET_DIR=$socket_dir" >"$staging_tmp" +install -m 600 -o "$operator" -g "$operator_group" "$production_tmp" "$production_fragment" +install -m 600 -o "$operator" -g "$operator_group" "$staging_tmp" "$staging_fragment" + +provision_finished=true + +echo "Provisioned isolated production and staging PostgreSQL roles and databases." +echo "Verified SCRAM authentication, citext, and PostGIS through $socket_dir." +echo "Credential fragments (mode 0600):" +echo " $production_fragment" +echo " $staging_fragment" +echo "Original HBA backup (mode 0600): $backup_file" diff --git a/scripts/quality.sh b/scripts/quality.sh index 55d59b5..907b85d 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -153,6 +153,38 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ ./scripts/init-production-env.sh help.test "$external_production_env" >/dev/null ./scripts/validate-production-env.sh "$external_production_env" help.test >/dev/null +external_socket_production_env="$scan_dir/.env.production.external-db-socket" +PRODUCTION_DATABASE_MODE=external \ +PRODUCTION_DATABASE_URL=ecto://quality:external-password@localhost/who_need_help \ +PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \ +PRODUCTION_SMTP_RELAY=smtp.help.test \ +PRODUCTION_SMTP_PORT=587 \ +PRODUCTION_SMTP_USERNAME=quality-user \ +PRODUCTION_SMTP_PASSWORD=quality-password \ +PRODUCTION_SMTP_AUTH=always \ +PRODUCTION_SMTP_TLS=always \ +PRODUCTION_SMTP_SSL=false \ +PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ +PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + ./scripts/init-production-env.sh help.test "$external_socket_production_env" >/dev/null +./scripts/validate-production-env.sh \ + "$external_socket_production_env" help.test >/dev/null +if PRODUCTION_DATABASE_MODE=external \ + PRODUCTION_DATABASE_URL=ecto://quality:external-password@localhost/who_need_help \ + PRODUCTION_DATABASE_SOCKET_DIR=relative/socket \ + PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + ./scripts/init-production-env.sh \ + help.test "$scan_dir/.env.production.invalid-socket" >/dev/null 2>&1; then + echo "Production initializer accepted a relative database socket path." >&2 + exit 1 +fi +if PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \ + PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + ./scripts/init-production-env.sh \ + help.test "$scan_dir/.env.production.container-socket" >/dev/null 2>&1; then + echo "Production initializer accepted a host socket in container database mode." >&2 + exit 1 +fi external_split_production_env="$scan_dir/.env.production.external-db-split" PRODUCTION_APP_TOPOLOGY=split \ PRODUCTION_DATABASE_MODE=external \ @@ -222,6 +254,7 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \ ./scripts/compose.sh .env.example config --quiet ./scripts/compose.sh "$production_env" config --quiet ./scripts/compose.sh "$external_production_env" config --quiet +./scripts/compose.sh "$external_socket_production_env" config --quiet ./scripts/compose.sh "$external_split_production_env" config --quiet ./scripts/compose.sh .env.example config --format json | jq --exit-status ' @@ -287,6 +320,29 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \ (.services | has("proxy") | not) and .services.app.networks.public_edge.aliases == ["who-need-help-production"] ' >/dev/null +./scripts/compose.sh "$external_socket_production_env" config --format json | + jq --exit-status ' + (.services | has("app")) and + (.services | has("db") | not) and + .services.app.environment.DATABASE_SOCKET_DIR == "/var/run/postgresql" and + .services.migrate.environment.DATABASE_SOCKET_DIR == "/var/run/postgresql" and + (.services.app.volumes | + any( + .type == "bind" and + .source == "/var/run/postgresql" and + .target == "/var/run/postgresql" and + .read_only == true + )) and + (.services.migrate.volumes | + any( + .type == "bind" and + .source == "/var/run/postgresql" and + .target == "/var/run/postgresql" and + .read_only == true + )) + ' >/dev/null +./scripts/compose.sh "$external_socket_production_env" config --profiles | + grep -Fx container-database >/dev/null ./scripts/compose.sh "$external_split_production_env" config --format json | jq --exit-status ' (.services | has("db") | not) and diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index f558256..2cd6791 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -88,6 +88,7 @@ public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS) postgres_password=$(optional_value POSTGRES_PASSWORD) database_url=$(require_value DATABASE_URL) +database_socket_dir=$(optional_value DATABASE_SOCKET_DIR) secret_key_base=$(require_value SECRET_KEY_BASE) handover_secret=$(require_value HANDOVER_SECRET) release_cookie=$(require_value RELEASE_COOKIE) @@ -203,6 +204,18 @@ else echo "External DATABASE_URL still targets the Compose db service." >&2 exit 1 fi + + if [[ -n "$database_socket_dir" ]]; then + [[ "$database_socket_dir" == /* ]] || { + echo "DATABASE_SOCKET_DIR must be an absolute path." >&2 + exit 1 + } + reject_marker DATABASE_SOCKET_DIR "$database_socket_dir" + fi +fi +if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then + echo "DATABASE_SOCKET_DIR is only valid for DATABASE_MODE=external." >&2 + exit 1 fi [[ "$smtp_relay" != mailpit ]] || {