#!/usr/bin/env bash set -euo pipefail umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) env_file=${1:-"$ROOT/.env"} target=${2:-} expected_environment=${3:-} if [[ "$env_file" != /* ]]; then env_file="$ROOT/$env_file" fi if [[ ! -f "$env_file" ]]; then echo "Environment file does not exist: $env_file" >&2 exit 2 fi if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then echo "Environment file must have mode 0600: $env_file" >&2 exit 2 fi read_value() { local key=$1 awk -v key="$key" ' index($0, key "=") == 1 { print substr($0, length(key) + 2) found = 1 exit } END { if (!found) exit 1 } ' "$env_file" } deployment_environment=$(read_value DEPLOYMENT_ENV) compose_project=$(read_value COMPOSE_PROJECT_NAME) database_mode=$(read_value DATABASE_MODE) if [[ "$database_mode" != "external" ]]; then echo "backup-external-postgres.sh requires DATABASE_MODE=external." >&2 exit 2 fi if [[ -n "$expected_environment" && "$deployment_environment" != "$expected_environment" ]]; then echo "Environment mismatch: expected $expected_environment." >&2 exit 2 fi for command in awk pg_dump pg_restore psql python3 sha256sum stat; do command -v "$command" >/dev/null 2>&1 || { echo "Required command is unavailable: $command" >&2 exit 2 } done mapfile -d '' -t connection_parts < <( python3 - "$env_file" <<'PY' import sys from pathlib import Path from urllib.parse import unquote, urlsplit values = {} for line in Path(sys.argv[1]).read_text().splitlines(): if "=" in line and not line.startswith("#"): key, value = line.split("=", 1) values.setdefault(key, value) raw_url = values.get("DATABASE_URL", "") if raw_url.startswith("ecto://"): raw_url = "postgresql://" + raw_url[len("ecto://"):] parsed = urlsplit(raw_url) required = { "host": parsed.hostname, "database": parsed.path.lstrip("/"), "username": parsed.username, "password": parsed.password, } missing = [name for name, value in required.items() if not value] if missing: raise SystemExit("DATABASE_URL is missing: " + ", ".join(missing)) parts = ( parsed.hostname, str(parsed.port or 5432), unquote(parsed.path.lstrip("/")), unquote(parsed.username), unquote(parsed.password), values.get("DATABASE_SOCKET_DIR", ""), ) sys.stdout.buffer.write(b"\0".join(part.encode() for part in parts) + b"\0") PY ) if [[ ${#connection_parts[@]} -ne 6 ]]; then echo "Could not parse the external PostgreSQL connection without exposing it." >&2 exit 2 fi database_host=${connection_parts[0]} database_port=${connection_parts[1]} database_name=${connection_parts[2]} database_user=${connection_parts[3]} database_password=${connection_parts[4]} database_socket_dir=${connection_parts[5]} connection_host=${database_socket_dir:-$database_host} escape_pgpass() { local escaped=$1 escaped=${escaped//\\/\\\\} escaped=${escaped//:/\\:} printf '%s' "$escaped" } pgpass_file=$(mktemp "${TMPDIR:-/tmp}/who-need-help-pgpass.XXXXXX") partial= checksum_partial= metadata_partial= cleanup() { rm -f "$pgpass_file" [[ -z "$partial" ]] || rm -f "$partial" [[ -z "$checksum_partial" ]] || rm -f "$checksum_partial" [[ -z "$metadata_partial" ]] || rm -f "$metadata_partial" } trap cleanup EXIT HUP INT TERM chmod 600 "$pgpass_file" printf '%s:%s:%s:%s:%s\n' \ "$(escape_pgpass "$database_host")" \ "$(escape_pgpass "$database_port")" \ "$(escape_pgpass "$database_name")" \ "$(escape_pgpass "$database_user")" \ "$(escape_pgpass "$database_password")" >"$pgpass_file" psql_args=( --host "$connection_host" --port "$database_port" --username "$database_user" --dbname "$database_name" --no-password --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 ) server_version=$( PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \ psql "${psql_args[@]}" --command="show server_version" ) server_database=$( PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \ psql "${psql_args[@]}" --command="select current_database()" ) if [[ "$server_database" != "$database_name" ]]; then echo "PostgreSQL connected to an unexpected database." >&2 exit 2 fi printf 'Environment: %s\n' "$deployment_environment" printf 'Compose project: %s\n' "$compose_project" printf 'Database mode: external\n' printf 'Database endpoint: %s:%s/%s\n' "$connection_host" "$database_port" "$database_name" printf 'PostgreSQL server: %s\n' "$server_version" printf 'PostgreSQL client: %s\n' "$(pg_dump --version)" if [[ "$target" == "--check-only" ]]; then echo "Read-only external PostgreSQL connection check passed." exit 0 fi if [[ -z "$target" ]]; then echo "Usage: $0 ENV_FILE OUTPUT_DUMP [EXPECTED_ENVIRONMENT]" >&2 echo " $0 ENV_FILE --check-only [EXPECTED_ENVIRONMENT]" >&2 exit 2 fi if [[ "$target" != /* ]]; then target="$ROOT/$target" fi target_dir=$(dirname -- "$target") target_name=$(basename -- "$target") checksum="$target.sha256" metadata="$target.metadata" if [[ -e "$target" || -e "$checksum" || -e "$metadata" ]]; then echo "Refusing to overwrite an existing backup, checksum, or metadata file." >&2 exit 2 fi mkdir -p "$target_dir" chmod 700 "$target_dir" partial="$target.partial.$$" checksum_partial="$checksum.partial.$$" metadata_partial="$metadata.partial.$$" printf 'Backup target: %s\n' "$target" echo "The source database is read only; the command will create one custom-format dump plus checksum and non-secret metadata." PGPASSFILE="$pgpass_file" pg_dump \ --host "$connection_host" \ --port "$database_port" \ --username "$database_user" \ --dbname "$database_name" \ --no-password \ --format custom \ --no-owner \ --no-acl \ --file "$partial" [[ -s "$partial" ]] || { echo "PostgreSQL produced an empty backup." >&2 exit 1 } pg_restore --list "$partial" >/dev/null hash=$(sha256sum "$partial" | awk '{print $1}') printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial" { printf 'deployment_environment=%s\n' "$deployment_environment" printf 'compose_project=%s\n' "$compose_project" printf 'database_name=%s\n' "$database_name" printf 'server_version=%s\n' "$server_version" printf 'client_version=%s\n' "$(pg_dump --version)" printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'sha256=%s\n' "$hash" } >"$metadata_partial" chmod 600 "$partial" "$checksum_partial" "$metadata_partial" sync -f "$partial" "$checksum_partial" "$metadata_partial" mv "$partial" "$target" mv "$checksum_partial" "$checksum" mv "$metadata_partial" "$metadata" partial= checksum_partial= metadata_partial= echo "External PostgreSQL backup catalog and checksum passed verification." printf 'Backup: %s\nChecksum: %s\nMetadata: %s\n' "$target" "$checksum" "$metadata"