who_need_help/scripts/backup-external-postgres.sh

246 lines
6.7 KiB
Bash
Executable File

#!/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"