who_need_help/scripts/backup-s3-drill.sh

632 lines
19 KiB
Bash
Executable File

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