Support host PostgreSQL through Unix sockets
Some checks are pending
Quality / full-local-gates (push) Waiting to run
Some checks are pending
Quality / full-local-gates (push) Waiting to run
This commit is contained in:
parent
585f5648ff
commit
27c615d6cb
|
|
@ -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
|
||||||
|
|
|
||||||
22
README.md
22
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
|
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
|
||||||
|
|
|
||||||
16
compose.external-db-socket.yaml
Normal file
16
compose.external-db-socket.yaml
Normal 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
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
242
scripts/provision-host-postgres.sh
Executable file
242
scripts/provision-host-postgres.sh
Executable 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ]] || {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user