#!/usr/bin/env bash set -Eeuo pipefail project_root=${PLATFORM_ROOT:-/opt/data-terminal-cloud/current} backup_root=${BACKUP_ROOT:-/var/backups/data-terminal-cloud} passphrase_file=${BACKUP_PASSPHRASE_FILE:-/etc/data-terminal-cloud/backup-passphrase} archive=${1:-} if [[ -z "$archive" ]]; then archive=$(find "$backup_root" -maxdepth 1 -type f -name 'platform-*.tar.gz.enc' -printf '%T@ %p\n' | sort -nr | head -n1 | cut -d' ' -f2-) fi if [[ -z "$archive" || ! -f "$archive" ]]; then echo "Backup archive was not found" >&2 exit 2 fi archive=$(realpath "$archive") backup_root=$(realpath "$backup_root") case "$archive" in "$backup_root"/platform-*.tar.gz.enc) ;; *) echo "Backup archive is outside the configured backup directory" >&2; exit 2 ;; esac if [[ ! -r "$passphrase_file" ]]; then echo "Backup passphrase file is missing or unreadable" >&2 exit 2 fi compose=(docker compose -f "$project_root/compose.yaml" -f "$project_root/deploy/server/compose.override.yaml" --env-file "$project_root/deploy/server/.env") postgres_container=$("${compose[@]}" ps -q postgres) if [[ -z "$postgres_container" ]] || [[ $(docker inspect -f '{{.State.Running}}' "$postgres_container") != true ]]; then echo "PostgreSQL service is not running" >&2 exit 3 fi redis_container=$("${compose[@]}" ps -q redis) if [[ -z "$redis_container" ]] || [[ $(docker inspect -f '{{.State.Running}}' "$redis_container") != true ]]; then echo "Redis service is not running" >&2 exit 3 fi umask 077 work=$(mktemp -d "$backup_root/.verify-XXXXXX") plain="$work/backup.tar.gz" verify_token=$(openssl rand -hex 12) restore_database="voice_restore_verify_$verify_token" redis_verify_path="/tmp/platform-backup-$verify_token.rdb" database_created=false cleanup() { status=$? if [[ "$database_created" == true ]]; then docker exec "$postgres_container" dropdb -U voice --if-exists "$restore_database" >/dev/null 2>&1 || true fi docker exec "$redis_container" rm -f -- "$redis_verify_path" >/dev/null 2>&1 || true rm -rf -- "$work" exit "$status" } trap cleanup EXIT INT TERM if [[ -f "$archive.sha256" ]]; then (cd "$(dirname "$archive")" && sha256sum -c "$(basename "$archive").sha256") >/dev/null fi openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ -pass "file:$passphrase_file" -in "$archive" -out "$plain" tar -tzf "$plain" >"$work/archive-paths.txt" if grep -Eq '(^/|(^|/)\.\.(/|$))' "$work/archive-paths.txt"; then echo "Backup contains an unsafe archive path" >&2 exit 4 fi mkdir -p "$work/extracted" tar -xzf "$plain" --no-same-owner -C "$work/extracted" ( cd "$work/extracted" sha256sum -c SHA256SUMS >/dev/null ) expected=$(tr -d '\r\n' <"$work/extracted/database-evidence.txt") docker exec "$postgres_container" createdb -U voice "$restore_database" database_created=true docker exec -i "$postgres_container" pg_restore -U voice -d "$restore_database" --no-owner --no-acl <"$work/extracted/postgres.dump" actual=$(docker exec "$postgres_container" psql -U voice -d "$restore_database" -Atc \ "SELECT version||'|'||dirty||'|'||(SELECT count(*) FROM tenant)||'|'||(SELECT count(*) FROM user_account)||'|'||(SELECT count(*) FROM information_schema.tables WHERE table_schema='public')||'|'||(SELECT count(*) FROM information_schema.table_constraints WHERE constraint_type='FOREIGN KEY') FROM schema_migrations;") if [[ "$actual" != "$expected" ]]; then echo "Restored database evidence does not match the backup: expected=$expected actual=$actual" >&2 exit 5 fi [[ -s "$work/extracted/redis/dump.rdb" ]] || { echo "Redis snapshot is missing" >&2; exit 6; } [[ -d "$work/extracted/object-store" ]] || { echo "Object-store snapshot is missing" >&2; exit 6; } [[ -d "$work/extracted/keycloak" ]] || { echo "Keycloak snapshot is missing" >&2; exit 6; } expected_object_files=$(sed -n 's/^object_files=//p' "$work/extracted/manifest.properties") expected_keycloak_files=$(sed -n 's/^keycloak_files=//p' "$work/extracted/manifest.properties") actual_object_files=$(find "$work/extracted/object-store" -type f | wc -l | tr -d ' ') actual_keycloak_files=$(find "$work/extracted/keycloak" -type f | wc -l | tr -d ' ') if [[ ! "$expected_object_files" =~ ^[0-9]+$ || "$actual_object_files" != "$expected_object_files" ]]; then echo "Object-store file count does not match the manifest" >&2 exit 6 fi if [[ ! "$expected_keycloak_files" =~ ^[1-9][0-9]*$ || "$actual_keycloak_files" != "$expected_keycloak_files" ]]; then echo "Keycloak file count does not match the manifest" >&2 exit 6 fi docker cp "$work/extracted/redis/dump.rdb" "$redis_container:$redis_verify_path" >/dev/null docker exec "$redis_container" redis-check-rdb "$redis_verify_path" >/dev/null docker exec "$redis_container" rm -f -- "$redis_verify_path" printf 'backup_restore_verified archive=%s database_evidence=%s object_files=%s keycloak_files=%s redis_rdb=valid\n' \ "$archive" "$actual" "$actual_object_files" "$actual_keycloak_files"