#!/usr/bin/env bash set -euo pipefail umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env.load" LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"} if [[ ! -f "$ENV_FILE" ]]; then echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2 exit 1 fi set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a for name in LOAD_PROJECT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD DATABASE_URL \ BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT BACKUP_MINIO_ROOT_USER \ BACKUP_MINIO_ROOT_PASSWORD BACKUP_RESTIC_PASSWORD BACKUP_BUCKET_PREFIX \ BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \ BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do if [[ -z "${!name:-}" ]]; then echo "$name is missing from .env.load" >&2 exit 1 fi done if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then echo "The encrypted backup drill must not use the staging Compose project." >&2 exit 1 fi if [[ ! "$LABEL" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then echo "Run label must be 1-32 lowercase letters, numbers, or dashes." >&2 exit 1 fi if [[ ! "$BACKUP_BUCKET_PREFIX" =~ ^[a-z0-9][a-z0-9-]{1,19}$ ]]; then echo "BACKUP_BUCKET_PREFIX must be 2-20 lowercase letters, numbers, or dashes." >&2 exit 1 fi for name in BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT; do if [[ ! "${!name}" =~ ^[0-9]+$ ]] || ((10#${!name} > 65535)); then echo "$name must be a TCP port number from 0 through 65535." >&2 exit 1 fi done for name in BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \ BACKUP_INTERRUPTION_CHUNK_BYTES; do if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then echo "$name must be a positive integer." >&2 exit 1 fi done if ! awk -v value="$BACKUP_INTERRUPTION_INTERVAL_SECONDS" \ 'BEGIN {exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)}'; then echo "BACKUP_INTERRUPTION_INTERVAL_SECONDS must be greater than zero." >&2 exit 1 fi for command in awk dd diff docker grep head jq od sleep tr unlink; do if ! command -v "$command" >/dev/null 2>&1; then echo "Required command is unavailable: $command" >&2 exit 1 fi done bucket="$BACKUP_BUCKET_PREFIX-$LABEL" corrupt_bucket="$bucket-corrupt" interrupt_bucket="$bucket-interrupt" for candidate in "$bucket" "$corrupt_bucket" "$interrupt_bucket"; do if ((${#candidate} > 63)); then echo "Derived S3 bucket name exceeds 63 characters: $candidate" >&2 exit 1 fi done runtime_dir="$ROOT/tmp/backup-s3/$LOAD_PROJECT/$LABEL" output_dir="$ROOT/output/backups-s3/$LABEL" mkdir -p "$runtime_dir" "$output_dir" chmod 700 "$ROOT/tmp" "$ROOT/tmp/backup-s3" \ "$ROOT/tmp/backup-s3/$LOAD_PROJECT" "$runtime_dir" \ "$ROOT/output" "$ROOT/output/backups-s3" "$output_dir" export BACKUP_RUNTIME_DIR="$runtime_dir" export BACKUP_HOST_UID export BACKUP_HOST_GID BACKUP_HOST_UID=$(id -u) BACKUP_HOST_GID=$(id -g) compose=( docker compose --env-file "$ENV_FILE" -p "$LOAD_PROJECT" -f compose.yaml -f compose.load.yaml -f compose.backup.yaml --profile backup ) service_ids() { "${compose[@]}" ps --all -q "$1" } assert_scope() { local container_id=$1 local expected_service=$2 local observed_project observed_service observed_project=$( docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' \ "$container_id" ) observed_service=$( docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}}' \ "$container_id" ) if [[ "$observed_project" != "$LOAD_PROJECT" || "$observed_service" != "$expected_service" ]]; then echo "Container scope mismatch for $container_id." >&2 exit 1 fi } mc_run() { "${compose[@]}" run --rm --no-deps -T minio-client "$@" } restic_run_repo() { local repository=$1 shift "${compose[@]}" run --rm --no-deps -T \ --env "RESTIC_REPOSITORY=$repository" \ backup-tools restic "$@" } database_snapshot() { # Variables are intentionally expanded inside the isolated PostGIS container. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' >"$1" <<'SQL' BEGIN READ ONLY; SELECT 'users' AS table_name, count(*) AS row_count FROM users UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens UNION ALL SELECT 'help_requests', count(*) FROM help_requests UNION ALL SELECT 'messages', count(*) FROM messages UNION ALL SELECT 'categories', count(*) FROM categories UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments UNION ALL SELECT 'activities', count(*) FROM activities UNION ALL SELECT 'reports', count(*) FROM reports UNION ALL SELECT 'social_identities', count(*) FROM social_identities UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions UNION ALL SELECT 'schema_migrations', count(*) FROM schema_migrations ORDER BY table_name; COMMIT; SQL } wait_for_minio() { local minio_id=$1 local deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do local state health state=$(docker inspect --format '{{.State.Status}}' "$minio_id") health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' \ "$minio_id") if [[ "$state" == "running" && "$health" == "healthy" ]]; then return 0 fi sleep 1 done echo "Timed out waiting for the scoped MinIO container." >&2 return 1 } remove_bucket() { local candidate=$1 mc_run rb --force "local/$candidate" >/dev/null 2>&1 || true } remove_bucket_and_verify() { local candidate=$1 mc_run rb --force "local/$candidate" >/dev/null if mc_run stat "local/$candidate" >/dev/null 2>&1; then echo "Temporary MinIO bucket was not removed: $candidate" >&2 exit 1 fi } remove_interrupt_container() { if [[ -n "${interrupt_container_id:-}" ]] && docker inspect "$interrupt_container_id" >/dev/null 2>&1; then assert_scope "$interrupt_container_id" backup-tools docker stop --time 1 "$interrupt_container_id" >/dev/null 2>&1 || true docker rm "$interrupt_container_id" >/dev/null 2>&1 || true fi } drop_drill_database() { if [[ "${drill_database_created:-false}" == "true" ]]; then "${compose[@]}" exec -T db \ dropdb --username "$POSTGRES_USER" --if-exists "$drill_db" >/dev/null 2>&1 || true fi } cleanup() { local status=$? trap - EXIT HUP INT TERM drop_drill_database remove_interrupt_container if [[ "${corrupt_bucket_created:-false}" == "true" ]]; then remove_bucket "$corrupt_bucket" fi if [[ "${interrupt_bucket_created:-false}" == "true" ]]; then remove_bucket "$interrupt_bucket" fi exit "$status" } trap cleanup EXIT HUP INT TERM for service in minio-permissions minio minio-client backup-tools; do while IFS= read -r existing_id; do [[ -n "$existing_id" ]] && assert_scope "$existing_id" "$service" done < <(service_ids "$service") done database_snapshot "$output_dir/database-before.txt" "${compose[@]}" build backup-tools minio minio-client \ >"$output_dir/backup-images-build.txt" "${compose[@]}" up -d --wait minio >"$output_dir/minio-up.txt" minio_id=$(service_ids minio | head -n 1) assert_scope "$minio_id" minio wait_for_minio "$minio_id" if mc_run stat "local/$bucket" >/dev/null 2>&1; then echo "Refusing to overwrite retained canonical bucket: $bucket" >&2 exit 1 fi mc_run mb "local/$bucket" >"$output_dir/canonical-bucket-create.txt" canonical_repository="s3:http://minio:9000/$bucket" restic_run_repo "$canonical_repository" init >"$output_dir/restic-init.txt" restic_run_repo "$canonical_repository" \ backup \ --json \ --host who-need-help-local \ --tag "$LABEL" \ --stdin-filename database.dump \ --stdin-from-command \ -- \ pg_dump \ --host db \ --username "$POSTGRES_USER" \ --dbname "$POSTGRES_DB" \ --format custom \ --no-owner \ >"$output_dir/backup.jsonl" restic_run_repo "$canonical_repository" snapshots --json --tag "$LABEL" \ >"$output_dir/snapshots.json" if ! jq -e --arg label "$LABEL" ' length == 1 and .[0].tags == [$label] and (.[0].id | length) == 64 ' "$output_dir/snapshots.json" >/dev/null; then echo "The canonical encrypted backup did not create exactly one tagged snapshot." >&2 exit 1 fi snapshot_id=$(jq -r '.[0].id' "$output_dir/snapshots.json") restic_run_repo "$canonical_repository" check --read-data \ >"$output_dir/canonical-check.txt" 2>&1 restic_run_repo "$canonical_repository" ls --json "$snapshot_id" \ >"$output_dir/snapshot-files.jsonl" if ! jq -s -e ' any( .[]; .struct_type == "node" and .path == "/database.dump" and .type == "file" and .size > 0 ) ' "$output_dir/snapshot-files.jsonl" >/dev/null; then echo "The encrypted snapshot does not contain a non-empty database.dump." >&2 exit 1 fi mc_run ls --recursive --json "local/$bucket" \ >"$output_dir/minio-objects.jsonl" if ! jq -s -e ' length > 0 and any(.[]; (.key // "") | startswith("data/")) ' "$output_dir/minio-objects.jsonl" >/dev/null; then echo "MinIO did not retain encrypted Restic data objects." >&2 exit 1 fi pack_key=$( jq -sr ' [.[] | select((.key // "") | startswith("data/")) | .key] | sort | first ' "$output_dir/minio-objects.jsonl" ) mc_run cat "local/$bucket/$pack_key" | dd of="$runtime_dir/encrypted-prefix.bin" bs=1 count=16 status=none od -An -tx1 -v "$runtime_dir/encrypted-prefix.bin" | tr -d ' \n' >"$output_dir/encrypted-prefix.hex" if grep -aFq 'PGDMP' "$runtime_dir/encrypted-prefix.bin"; then echo "A Restic data object exposed the PostgreSQL custom-format header." >&2 exit 1 fi restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump | "${compose[@]}" exec -T db pg_restore --list \ >"$output_dir/pg-restore-list.txt" drill_db="wnh_s3_restore_${LABEL//-/_}_$$" "${compose[@]}" exec -T db \ createdb \ --username "$POSTGRES_USER" \ --template template0 \ "$drill_db" drill_database_created=true restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump | "${compose[@]}" exec -T db \ pg_restore \ --username "$POSTGRES_USER" \ --dbname "$drill_db" \ --exit-on-error \ --no-owner \ --no-privileges "${compose[@]}" exec -T db \ psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$drill_db" \ >"$output_dir/restored-database.json" <<'SQL' SELECT json_build_object( 'public_table_count', ( SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'spatial_ref_sys' ), 'migration_count', (SELECT count(*) FROM schema_migrations), 'user_count', (SELECT count(*) FROM users), 'request_count', (SELECT count(*) FROM help_requests), 'message_count', (SELECT count(*) FROM messages), 'category_count', (SELECT count(*) FROM categories), 'postgis_version', PostGIS_Lib_Version() ); SQL if ! jq -e ' .public_table_count > 0 and .migration_count > 0 and .category_count > 0 and (.postgis_version | type == "string" and length > 0) ' "$output_dir/restored-database.json" >/dev/null; then echo "The fresh restore database failed structural validation." >&2 exit 1 fi case "$DATABASE_URL" in *\?*) database_base=${DATABASE_URL%%\?*} database_query="?${DATABASE_URL#*\?}" ;; *) database_base=$DATABASE_URL database_query= ;; esac database_prefix=${database_base%/*} if [[ "$database_prefix" == "$database_base" ]]; then echo "Could not derive the temporary drill DATABASE_URL." >&2 exit 1 fi drill_database_url="$database_prefix/$drill_db$database_query" "${compose[@]}" run --rm --no-deps \ --env APP_ROLE=migrate \ --env "DATABASE_URL=$drill_database_url" \ migrate /app/bin/migrate \ >"$output_dir/restore-migrate.txt" "${compose[@]}" run --rm --no-deps \ --env APP_ROLE=migrate \ --env "DATABASE_URL=$drill_database_url" \ migrate /app/bin/who_need_help eval 'WhoNeedHelp.Release.await_migrations()' \ >"$output_dir/restore-migration-readiness.txt" "${compose[@]}" exec -T db \ dropdb --username "$POSTGRES_USER" "$drill_db" drill_database_created=false remaining=$( "${compose[@]}" exec -T db \ psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname postgres \ --command "SELECT count(*) FROM pg_database WHERE datname = '$drill_db';" ) if [[ "$remaining" != "0" ]]; then echo "Restore drill database was not removed: $drill_db" >&2 exit 1 fi mc_run mb "local/$corrupt_bucket" >"$output_dir/corrupt-bucket-create.txt" corrupt_bucket_created=true mc_run mirror "local/$bucket" "local/$corrupt_bucket" \ >"$output_dir/corrupt-mirror.txt" mc_run cp "local/$corrupt_bucket/config" /work/corrupt-config \ >"$output_dir/corrupt-download.txt" first_byte=$(od -An -tu1 -N1 "$runtime_dir/corrupt-config" | tr -d ' ') if [[ "$first_byte" == "0" ]]; then printf '\001' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none else printf '\000' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none fi mc_run cp /work/corrupt-config "local/$corrupt_bucket/config" \ >"$output_dir/corrupt-upload.txt" corrupt_repository="s3:http://minio:9000/$corrupt_bucket" set +e restic_run_repo "$corrupt_repository" check --read-data \ >"$output_dir/corrupt-check.txt" 2>&1 corrupt_check_status=$? restic_run_repo "$corrupt_repository" dump "$snapshot_id" /database.dump \ >"/dev/null" 2>"$output_dir/corrupt-restore.txt" corrupt_restore_status=$? set -e if [[ "$corrupt_check_status" -eq 0 || "$corrupt_restore_status" -eq 0 ]]; then echo "The corrupted encrypted repository did not fail closed." >&2 exit 1 fi remove_bucket_and_verify "$corrupt_bucket" corrupt_bucket_created=false unlink "$runtime_dir/corrupt-config" mc_run mb "local/$interrupt_bucket" >"$output_dir/interrupt-bucket-create.txt" interrupt_bucket_created=true interrupt_repository="s3:http://minio:9000/$interrupt_bucket" restic_run_repo "$interrupt_repository" init >"$output_dir/interrupt-init.txt" interrupt_container="$LOAD_PROJECT-backup-interrupt-$LABEL" if docker inspect "$interrupt_container" >/dev/null 2>&1; then echo "Refusing to replace an existing interruption container: $interrupt_container" >&2 exit 1 fi # The producer variables must expand inside the isolated backup-tools container. # shellcheck disable=SC2016 interrupt_container_id=$( "${compose[@]}" run -d \ --name "$interrupt_container" \ --no-deps \ --env "RESTIC_REPOSITORY=$interrupt_repository" \ backup-tools \ restic backup \ --json \ --host who-need-help-local \ --tag "$LABEL-interrupted" \ --stdin-filename interrupted.bin \ --stdin-from-command \ -- \ /bin/sh -euc \ 'i=0 while [ "$i" -lt "$BACKUP_INTERRUPTION_CHUNKS" ]; do head -c "$BACKUP_INTERRUPTION_CHUNK_BYTES" /dev/urandom sleep "$BACKUP_INTERRUPTION_INTERVAL_SECONDS" i=$((i + 1)) done' ) assert_scope "$interrupt_container_id" backup-tools upload_deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS)) uploaded_data_objects=0 while ((SECONDS < upload_deadline)); do mc_run ls --recursive --json "local/$interrupt_bucket/data" \ >"$output_dir/interrupt-objects-current.jsonl" 2>/dev/null || true uploaded_data_objects=$( jq -s '[.[] | select(.type == "file")] | length' \ "$output_dir/interrupt-objects-current.jsonl" ) if ((uploaded_data_objects > 0)); then break fi if [[ "$(docker inspect --format '{{.State.Running}}' "$interrupt_container_id")" != "true" ]]; then echo "Interruption producer exited before an uploaded data object was observed." >&2 exit 1 fi sleep 1 done if ((uploaded_data_objects == 0)); then echo "Timed out waiting for the interruption upload to reach MinIO." >&2 exit 1 fi docker stop --time 1 "$interrupt_container_id" >"$output_dir/interrupt-stop.txt" docker inspect "$interrupt_container_id" | jq '.[0] | { id: .Id, project: .Config.Labels["com.docker.compose.project"], service: .Config.Labels["com.docker.compose.service"], exit_code: .State.ExitCode, state: .State.Status }' >"$output_dir/interrupt-container.json" docker logs "$interrupt_container_id" >"$output_dir/interrupt-backup.log" 2>&1 if ! jq -e '.exit_code != 0 and .state == "exited"' \ "$output_dir/interrupt-container.json" >/dev/null; then echo "The interrupted upload container did not exit unsuccessfully." >&2 exit 1 fi restic_run_repo "$interrupt_repository" snapshots \ --json --tag "$LABEL-interrupted" \ >"$output_dir/interrupt-snapshots.json" if ! jq -e 'length == 0' "$output_dir/interrupt-snapshots.json" >/dev/null; then echo "An interrupted upload published a restorable snapshot." >&2 exit 1 fi mc_run ls --incomplete --recursive --json "local/$interrupt_bucket" \ >"$output_dir/interrupt-incomplete-uploads.jsonl" 2>/dev/null || true restic_run_repo "$interrupt_repository" check --read-data \ >"$output_dir/interrupt-check-before-prune.txt" 2>&1 restic_run_repo "$interrupt_repository" prune \ >"$output_dir/interrupt-prune.txt" 2>&1 restic_run_repo "$interrupt_repository" check --read-data \ >"$output_dir/interrupt-check-after-prune.txt" 2>&1 docker rm "$interrupt_container_id" >"$output_dir/interrupt-remove.txt" interrupt_container_id= remove_bucket_and_verify "$interrupt_bucket" interrupt_bucket_created=false database_snapshot "$output_dir/database-after.txt" if ! diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \ >"$output_dir/database-diff.txt"; then echo "The encrypted backup drill changed source database counts." >&2 exit 1 fi minio_api=$("${compose[@]}" port minio 9000 | head -n 1) minio_console=$("${compose[@]}" port minio 9001 | head -n 1) if [[ ! "$minio_api" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ || ! "$minio_console" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ ]]; then echo "MinIO ports are not bound to observed loopback addresses." >&2 exit 1 fi jq -n \ --arg run_id "$LABEL" \ --arg bucket "$bucket" \ --arg snapshot_id "$snapshot_id" \ --arg minio_api "http://$minio_api" \ --arg minio_console "http://$minio_console" \ --argjson corrupt_check_exit "$corrupt_check_status" \ --argjson corrupt_restore_exit "$corrupt_restore_status" \ --argjson interrupted_uploaded_objects "$uploaded_data_objects" \ '{ run_id: $run_id, retained_bucket: $bucket, snapshot_id: $snapshot_id, encrypted_repository_check: "passed", fresh_database_restore: "passed", source_database_count_diff_bytes: 0, corrupted_check_exit: $corrupt_check_exit, corrupted_restore_exit: $corrupt_restore_exit, interrupted_uploaded_objects: $interrupted_uploaded_objects, interrupted_snapshot_count: 0, interrupted_repository_check_after_prune: "passed", minio_api: $minio_api, minio_console: $minio_console }' >"$output_dir/summary.json" trap - EXIT HUP INT TERM printf 'Encrypted S3 backup evidence: %s\n' "$output_dir" printf 'Retained MinIO bucket: %s\nSnapshot: %s\n' "$bucket" "$snapshot_id" printf 'MinIO API: http://%s\nMinIO console: http://%s\n' \ "$minio_api" "$minio_console"