From a2b7e77cc484787b8adf6745ccc4299b7f46c185 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Wed, 22 Jul 2026 00:55:24 +0200 Subject: [PATCH] feat: add guarded backup restore workflow --- scripts/backup/restore.sh | 304 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100755 scripts/backup/restore.sh diff --git a/scripts/backup/restore.sh b/scripts/backup/restore.sh new file mode 100755 index 0000000..3085baa --- /dev/null +++ b/scripts/backup/restore.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly CONFIRM_REPLACE_LIVE_DATA="--confirm-replace-live-data" +readonly STAGING_CONTAINER_ROOT="/staging" +readonly -a LIVE_SERVICES=(app worker bot) + +RESTORED_DUMP="" +RESTORED_UPLOADS="" +RESTORED_TDLIB_WORKER="" +RESTORED_TDLIB_BOT="" +LIVE_STAGING_DIR="" +SAFETY_DUMP="" +LIVE_RESTORE_ACTIVE=0 + +usage() { + cat <<'EOF' +Usage: + ./scripts/backup/restore.sh list + ./scripts/backup/restore.sh verify SNAPSHOT_ID + ./scripts/backup/restore.sh restore-to-staging SNAPSHOT_ID STAGING_DIR + ./scripts/backup/restore.sh restore-live SNAPSHOT_ID --confirm-replace-live-data +EOF +} + +require_value() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + printf 'Required environment variable %s is not set.\n' "$name" >&2 + return 1 + fi +} + +validate_environment() { + require_value BACKUP_MOUNT_PATH + require_value BACKUP_STAGING_PATH + require_value BACKUP_RESTIC_PASSWORD_FILE + if [[ ! -r "$BACKUP_RESTIC_PASSWORD_FILE" ]]; then + printf 'Restic password file %s is not readable.\n' "$BACKUP_RESTIC_PASSWORD_FILE" >&2 + return 1 + fi +} + +backup_restic() { + docker compose --profile backup run --rm --no-deps backup "$@" +} + +canonical_staging_root() { + realpath -m -- "$BACKUP_STAGING_PATH" +} + +validate_staging_directory() { + local staging_dir="$1" + local staging_root + local canonical_staging_dir + + staging_root="$(canonical_staging_root)" + canonical_staging_dir="$(realpath -m -- "$staging_dir")" + if [[ "$canonical_staging_dir" == "$staging_root" || "$canonical_staging_dir" != "$staging_root"/* ]]; then + printf 'Staging directory %s must be inside BACKUP_STAGING_PATH (%s).\n' "$staging_dir" "$staging_root" >&2 + return 1 + fi + printf '%s\n' "$canonical_staging_dir" +} + +container_staging_directory() { + local host_staging_dir="$1" + local staging_root + + staging_root="$(canonical_staging_root)" + printf '%s/%s\n' "$STAGING_CONTAINER_ROOT" "${host_staging_dir#"$staging_root"/}" +} + +verify_custom_dump() { + local dump_path="$1" + docker compose --profile backup run --rm --no-deps \ + --entrypoint pg_restore \ + -v "$dump_path:/restore/database.dump:ro" \ + backup --list /restore/database.dump >/dev/null +} + +validate_restored_tree() { + local staging_dir="$1" + local manifest + + RESTORED_DUMP="$(find "$staging_dir" -type f -name database.dump -size +0c -print -quit)" + manifest="$(find "$staging_dir" -type f -path '*/manifest/backup-manifest.json' -size +0c -print -quit)" + RESTORED_UPLOADS="$(find "$staging_dir" -type d \( -path '*/data/uploads' -o -name manual_uploads \) -print -quit)" + RESTORED_TDLIB_WORKER="$(find "$staging_dir" -type d -name tdlib-worker -print -quit)" + RESTORED_TDLIB_BOT="$(find "$staging_dir" -type d -name tdlib-bot -print -quit)" + if [[ -z "$RESTORED_DUMP" || -z "$manifest" || -z "$RESTORED_UPLOADS" || -z "$RESTORED_TDLIB_WORKER" || -z "$RESTORED_TDLIB_BOT" ]]; then + printf 'Restored tree %s is incomplete; require database.dump, manifest, manual_uploads, tdlib-worker, and tdlib-bot.\n' "$staging_dir" >&2 + return 1 + fi + verify_custom_dump "$RESTORED_DUMP" +} + +verify_snapshot() { + local snapshot_id="$1" + backup_restic snapshots "$snapshot_id" + backup_restic check +} + +restore_to_staging() { + local snapshot_id="$1" + local requested_staging_dir="$2" + local staging_dir + local container_staging_dir + validate_environment + staging_dir="$(validate_staging_directory "$requested_staging_dir")" + container_staging_dir="$(container_staging_directory "$staging_dir")" + mkdir -p -- "$staging_dir" + verify_snapshot "$snapshot_id" + backup_restic restore "$snapshot_id" --target "$container_staging_dir" + validate_restored_tree "$staging_dir" + printf 'Staging restore verified: %s\n' "$staging_dir" +} + +compose_project_name() { + local project_name + project_name="$(docker compose config --format json | awk -F'"' '/^[[:space:]]*"name"[[:space:]]*:/ { print $4; exit }')" + if [[ -z "$project_name" ]]; then + printf '%s\n' 'Unable to determine the Docker Compose project name.' >&2 + return 1 + fi + printf '%s\n' "$project_name" +} + +compose_volume_name() { + local project_name="$1" + local logical_name="$2" + local -a matches=() + mapfile -t matches < <(docker volume ls \ + --filter "label=com.docker.compose.project=$project_name" \ + --filter "label=com.docker.compose.volume=$logical_name" \ + --format '{{.Name}}') + if ((${#matches[@]} != 1)); then + printf 'Expected exactly one %s volume for Compose project %s; found %s.\n' "$logical_name" "$project_name" "${#matches[@]}" >&2 + return 1 + fi + printf '%s\n' "${matches[0]}" +} + +replace_volume() { + local source_dir="$1" + local volume_name="$2" + docker compose --profile backup run --rm --no-deps \ + --entrypoint bash \ + -v "$source_dir:/restore-source:ro" \ + -v "$volume_name:/restore-target" \ + backup -ceu ' + find /restore-target -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + cp -a /restore-source/. /restore-target/ + ' +} + +create_safety_dump() { + local database_user="${POSTGRES_USER:-dragons}" + local database_name="${POSTGRES_DB:-dragonsstash}" + SAFETY_DUMP="$LIVE_STAGING_DIR/pre-restore-database.dump" + docker compose exec -T db pg_dump --format=custom --no-owner \ + --username "$database_user" --dbname "$database_name" > "$SAFETY_DUMP" + if [[ ! -s "$SAFETY_DUMP" ]]; then + printf 'Safety database dump %s is missing or empty.\n' "$SAFETY_DUMP" >&2 + return 1 + fi +} + +restore_database() { + local database_user="${POSTGRES_USER:-dragons}" + local database_name="${POSTGRES_DB:-dragonsstash}" + docker compose exec -T db dropdb --if-exists --force --username "$database_user" "$database_name" + docker compose exec -T db createdb --username "$database_user" "$database_name" + docker compose exec -T db pg_restore --no-owner --exit-on-error \ + --username "$database_user" --dbname "$database_name" < "$RESTORED_DUMP" +} + +verify_file_references() { + local uploads_volume="$1" + local database_user="${POSTGRES_USER:-dragons}" + local database_name="${POSTGRES_DB:-dragonsstash}" + docker compose exec -T db psql --no-psqlrc --tuples-only --no-align --quiet \ + --username "$database_user" --dbname "$database_name" \ + --command 'SELECT "filePath" FROM "manual_upload_files" ORDER BY "filePath"' | + docker compose --profile backup run --rm --no-deps -T --entrypoint bash \ + -v "$uploads_volume:/data/uploads:ro" backup -ceu ' + missing=0 + while IFS= read -r file_path; do + case "$file_path" in + /data/uploads/*) relative_path="${file_path#/data/uploads/}" ;; + *) + printf "Database reference is outside /data/uploads: %s\\n" "$file_path" >&2 + missing=1 + continue + ;; + esac + if [[ -z "$relative_path" || "$relative_path" == .. || "$relative_path" == ../* || "$relative_path" == */../* ]]; then + printf "Database reference has an invalid uploads path: %s\\n" "$file_path" >&2 + missing=1 + elif [[ ! -f "/data/uploads/$relative_path" ]]; then + printf "Missing restored upload for database reference: %s\\n" "$file_path" >&2 + missing=1 + fi + done + exit "$missing" + ' +} + +wait_for_health() { + local health_url="${RESTORE_HEALTH_URL:-http://localhost:${APP_PORT:-3000}/api/health}" + local attempt + for attempt in {1..30}; do + if curl --fail --silent --show-error "$health_url" >/dev/null; then + return 0 + fi + sleep 2 + done + printf 'Application health endpoint did not become ready: %s\n' "$health_url" >&2 + return 1 +} + +live_restore_failure() { + local exit_code=$? + trap - EXIT + if ((LIVE_RESTORE_ACTIVE)); then + docker compose --profile full stop "${LIVE_SERVICES[@]}" || true + printf 'Live restore failed (exit %s). Services remain stopped. Staging directory: %s\n' "$exit_code" "$LIVE_STAGING_DIR" >&2 + printf 'Safety database dump retained at: %s\n' "$SAFETY_DUMP" >&2 + fi + exit "$exit_code" +} + +restore_live() { + local snapshot_id="$1" + local project_name + local uploads_volume + local worker_volume + local bot_volume + local timestamp + local container_staging_dir + validate_environment + docker compose --profile backup config --quiet + project_name="$(compose_project_name)" + verify_snapshot "$snapshot_id" + printf 'Live restore target confirmed: Compose project %s, configured Restic repository, snapshot %s.\n' "$project_name" "$snapshot_id" + timestamp="$(date -u +'%Y-%m-%dT%H-%M-%SZ')" + LIVE_STAGING_DIR="$(validate_staging_directory "$BACKUP_STAGING_PATH/live-restore-$timestamp-$$")" + container_staging_dir="$(container_staging_directory "$LIVE_STAGING_DIR")" + mkdir -p -- "$LIVE_STAGING_DIR" + SAFETY_DUMP="$LIVE_STAGING_DIR/pre-restore-database.dump" + trap live_restore_failure EXIT + LIVE_RESTORE_ACTIVE=1 + docker compose --profile full stop "${LIVE_SERVICES[@]}" + create_safety_dump + backup_restic restore "$snapshot_id" --target "$container_staging_dir" + validate_restored_tree "$LIVE_STAGING_DIR" + uploads_volume="$(compose_volume_name "$project_name" manual_uploads)" + worker_volume="$(compose_volume_name "$project_name" tdlib_state)" + bot_volume="$(compose_volume_name "$project_name" tdlib_bot_state)" + replace_volume "$RESTORED_UPLOADS" "$uploads_volume" + replace_volume "$RESTORED_TDLIB_WORKER" "$worker_volume" + replace_volume "$RESTORED_TDLIB_BOT" "$bot_volume" + restore_database + docker compose --profile full up -d "${LIVE_SERVICES[@]}" + wait_for_health + verify_file_references "$uploads_volume" + LIVE_RESTORE_ACTIVE=0 + trap - EXIT + printf 'Live restore completed. Staging directory retained at: %s\n' "$LIVE_STAGING_DIR" + printf 'Safety database dump retained at: %s\n' "$SAFETY_DUMP" +} + +main() { + local command="${1:-}" + case "$command" in + list) + [[ $# -eq 1 ]] || { usage >&2; return 2; } + validate_environment + backup_restic snapshots + ;; + verify) + [[ $# -eq 2 ]] || { usage >&2; return 2; } + validate_environment + verify_snapshot "$2" + ;; + restore-to-staging) + [[ $# -eq 3 ]] || { usage >&2; return 2; } + restore_to_staging "$2" "$3" + ;; + restore-live) + if [[ $# -ne 3 || "$3" != "$CONFIRM_REPLACE_LIVE_DATA" ]]; then + printf 'restore-live requires the exact confirmation flag: %s\n' "$CONFIRM_REPLACE_LIVE_DATA" >&2 + return 2 + fi + restore_live "$2" + ;; + *) + usage >&2 + return 2 + ;; + esac +} + +main "$@"