246 lines
6.7 KiB
Bash
Executable File
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"
|