Support host PostgreSQL through Unix sockets
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-21 04:35:03 +03:00
parent 585f5648ff
commit 27c615d6cb
11 changed files with 454 additions and 7 deletions

View File

@ -100,6 +100,10 @@ POSTGRES_DB=who_need_help
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret
DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help 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 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2 WORKER_POOL_SIZE=2

View File

@ -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 credentials. Provider-specific CA/network requirements still have to be
configured from that provider's verified documentation. 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 `compose.production.yaml` leaves local Mailpit stopped. Validation deliberately
fails while the relay still points to Mailpit or a template marker remains. 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 It does not claim to test DNS, certificates, actual mail delivery, the

View File

@ -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

View File

@ -3,6 +3,7 @@ name: who_need_help
x-app-environment: &app-environment x-app-environment: &app-environment
APP_ROLE: web APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} 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} SECRET_KEY_BASE: ${SECRET_KEY_BASE:?Set SECRET_KEY_BASE in .env}
HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env} HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env}
RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env} RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env}

View File

@ -339,13 +339,33 @@ if config_env() == :prod do
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :who_need_help, WhoNeedHelp.Repo, database_socket_dir =
# ssl: true, case System.get_env("DATABASE_SOCKET_DIR") do
url: database_url, value when value in [nil, ""] ->
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), nil
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4, "/" <> _rest = value ->
socket_options: maybe_ipv6 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. # 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 # A default value is used in config/dev.exs and config/test.exs but you

View File

@ -161,6 +161,59 @@ run-scoped account.
### External PostgreSQL/PostGIS ### 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 Provision the database and role first, then generate the environment without
placing its credentials on a command line that is retained in shell history: placing its credentials on a command line that is retained in shell history:

View File

@ -56,6 +56,7 @@ esac
database_url=$(read_env_value DATABASE_URL 2>/dev/null || true) database_url=$(read_env_value DATABASE_URL 2>/dev/null || true)
: "${database_url:?Set DATABASE_URL in $env_file}" : "${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 if [[ "$database_mode" == container ]]; then
postgres_db=$(read_env_value POSTGRES_DB 2>/dev/null || true) postgres_db=$(read_env_value POSTGRES_DB 2>/dev/null || true)
@ -86,6 +87,10 @@ compose=(
if [[ "$database_mode" == external ]]; then if [[ "$database_mode" == external ]]; then
compose+=(--file "$ROOT/compose.external-db.yaml") compose+=(--file "$ROOT/compose.external-db.yaml")
if [[ -n "$database_socket_dir" ]]; then
compose+=(--file "$ROOT/compose.external-db-socket.yaml")
fi
fi fi
if [[ "$app_topology" == compact ]]; then if [[ "$app_topology" == compact ]]; then

View File

@ -83,6 +83,19 @@ case "$database_mode" in
;; ;;
esac 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 case "$app_topology" in
compact | split) ;; compact | split) ;;
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;; *) 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 \ TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
POSTGRES_PASSWORD_VALUE=$postgres_password \ POSTGRES_PASSWORD_VALUE=$postgres_password \
DATABASE_URL_VALUE=$database_url \ DATABASE_URL_VALUE=$database_url \
DATABASE_SOCKET_DIR_VALUE=$database_socket_dir \
SECRET_KEY_BASE_VALUE=$secret_key_base \ SECRET_KEY_BASE_VALUE=$secret_key_base \
HANDOVER_SECRET_VALUE=$handover_secret \ HANDOVER_SECRET_VALUE=$handover_secret \
RELEASE_COOKIE_VALUE=$release_cookie \ RELEASE_COOKIE_VALUE=$release_cookie \
@ -166,6 +180,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"] replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"] replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"]
replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_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["SECRET_KEY_BASE"] = ENVIRON["SECRET_KEY_BASE_VALUE"]
replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"] replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"] replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]

View File

@ -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" <<SQL
SET password_encryption = 'scram-sha-256';
CREATE ROLE $production_role LOGIN PASSWORD '$production_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE ROLE $staging_role LOGIN PASSWORD '$staging_password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE DATABASE $production_database OWNER $production_role TEMPLATE template0;
CREATE DATABASE $staging_database OWNER $staging_role TEMPLATE template0;
REVOKE CONNECT ON DATABASE $production_database FROM PUBLIC;
REVOKE CONNECT ON DATABASE $staging_database FROM PUBLIC;
GRANT CONNECT ON DATABASE $production_database TO $production_role;
GRANT CONNECT ON DATABASE $staging_database TO $staging_role;
\connect $production_database
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS postgis;
\connect $staging_database
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS postgis;
SQL
chmod 600 "$sql_file"
chown postgres:postgres "$sql_file"
"${postgres_psql[@]}" --dbname postgres --file "$sql_file"
PGPASSWORD="$production_password" psql --no-psqlrc --set ON_ERROR_STOP=1 \
--host "$socket_dir" --username "$production_role" \
--dbname "$production_database" --quiet --tuples-only --no-align \
--command 'SELECT current_user, current_database(), PostGIS_Version()' >/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"

View File

@ -153,6 +153,38 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
./scripts/init-production-env.sh help.test "$external_production_env" >/dev/null ./scripts/init-production-env.sh help.test "$external_production_env" >/dev/null
./scripts/validate-production-env.sh "$external_production_env" help.test >/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" external_split_production_env="$scan_dir/.env.production.external-db-split"
PRODUCTION_APP_TOPOLOGY=split \ PRODUCTION_APP_TOPOLOGY=split \
PRODUCTION_DATABASE_MODE=external \ 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 .env.example config --quiet
./scripts/compose.sh "$production_env" config --quiet ./scripts/compose.sh "$production_env" config --quiet
./scripts/compose.sh "$external_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 "$external_split_production_env" config --quiet
./scripts/compose.sh .env.example config --format json | ./scripts/compose.sh .env.example config --format json |
jq --exit-status ' jq --exit-status '
@ -287,6 +320,29 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \
(.services | has("proxy") | not) and (.services | has("proxy") | not) and
.services.app.networks.public_edge.aliases == ["who-need-help-production"] .services.app.networks.public_edge.aliases == ["who-need-help-production"]
' >/dev/null ' >/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 | ./scripts/compose.sh "$external_split_production_env" config --format json |
jq --exit-status ' jq --exit-status '
(.services | has("db") | not) and (.services | has("db") | not) and

View File

@ -88,6 +88,7 @@ public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME)
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS) trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
postgres_password=$(optional_value POSTGRES_PASSWORD) postgres_password=$(optional_value POSTGRES_PASSWORD)
database_url=$(require_value DATABASE_URL) database_url=$(require_value DATABASE_URL)
database_socket_dir=$(optional_value DATABASE_SOCKET_DIR)
secret_key_base=$(require_value SECRET_KEY_BASE) secret_key_base=$(require_value SECRET_KEY_BASE)
handover_secret=$(require_value HANDOVER_SECRET) handover_secret=$(require_value HANDOVER_SECRET)
release_cookie=$(require_value RELEASE_COOKIE) release_cookie=$(require_value RELEASE_COOKIE)
@ -203,6 +204,18 @@ else
echo "External DATABASE_URL still targets the Compose db service." >&2 echo "External DATABASE_URL still targets the Compose db service." >&2
exit 1 exit 1
fi 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 fi
[[ "$smtp_relay" != mailpit ]] || { [[ "$smtp_relay" != mailpit ]] || {