#!/bin/bash set -euo pipefail RESTORE_TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BORG_CONTAINER="${BORG_CONTAINER:-borg-ui}" BORG_RESTORE_HOST_ROOT="${BORG_RESTORE_HOST_ROOT:-/mnt/user/appdata/borg-ui/restore}" BORG_PASSPHRASE_FILE_DEFAULT="${BORG_PASSPHRASE_FILE_DEFAULT:-/mnt/user/appdata/secrets/borg_repo_passphrase.txt}" require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Missing command: $1" >&2 exit 1 } } require_path() { [ -e "$1" ] || { echo "Missing path: $1" >&2 exit 1 } } require_borg_container() { docker inspect "$BORG_CONTAINER" >/dev/null 2>&1 || { echo "Missing Borg container: $BORG_CONTAINER" >&2 exit 1 } [ "$(docker inspect -f '{{.State.Running}}' "$BORG_CONTAINER" 2>/dev/null)" = "true" ] || { echo "Borg container is not running: $BORG_CONTAINER" >&2 exit 1 } docker exec "$BORG_CONTAINER" test -r /data/borg.db >/dev/null 2>&1 || { echo "Missing borg-ui database in container: $BORG_CONTAINER:/data/borg.db" >&2 exit 1 } docker exec "$BORG_CONTAINER" test -r /local/secrets/borg_repo_passphrase.txt >/dev/null 2>&1 || { echo "Missing Borg passphrase in container: $BORG_CONTAINER:/local/secrets/borg_repo_passphrase.txt" >&2 echo "Host path exists, but borg-ui must mount it as /local/secrets/borg_repo_passphrase.txt." >&2 exit 1 } } latest_archive_name() { require_borg_container docker exec -i "$BORG_CONTAINER" python3 - <<'PY' import sqlite3 conn = sqlite3.connect('/data/borg.db') cur = conn.cursor() cur.execute("select archive_name from backup_jobs where status in ('completed', 'completed_with_warnings') order by created_at desc limit 1") row = cur.fetchone() if not row: raise SystemExit("No completed borg archive found") print(row[0]) PY } borg_repo_url() { require_borg_container docker exec -i "$BORG_CONTAINER" python3 - <<'PY' import sqlite3 conn = sqlite3.connect('/data/borg.db') cur = conn.cursor() cur.execute("select path from repositories where path is not null and path != '' order by id asc limit 1") row = cur.fetchone() if not row: raise SystemExit("No borg repository configured") print(row[0]) PY } borg_extract() { local extract_dir="$1" shift local paths=("$@") require_borg_container docker exec -i "$BORG_CONTAINER" python3 - "$extract_dir" "${paths[@]}" <<'PY' import os, sys, subprocess extract_dir = sys.argv[1] paths = sys.argv[2:] import sqlite3 conn = sqlite3.connect('/data/borg.db') cur = conn.cursor() cur.execute("select path from repositories where path is not null and path != '' order by id asc limit 1") repo = cur.fetchone()[0] cur.execute("select archive_name from backup_jobs where status in ('completed', 'completed_with_warnings') order by created_at desc limit 1") archive = cur.fetchone()[0] with open('/local/secrets/borg_repo_passphrase.txt', 'r', encoding='utf-8') as f: os.environ['BORG_PASSPHRASE'] = f.read().strip() known_hosts = '/data/known_hosts' if os.path.exists(known_hosts): os.environ.setdefault( 'BORG_RSH', f'ssh -o UserKnownHostsFile={known_hosts} -o StrictHostKeyChecking=yes', ) os.makedirs(extract_dir, exist_ok=True) os.chdir(extract_dir) subprocess.run(['borg', 'extract', f'{repo}::{archive}', *paths], check=True) PY } write_report() { local report_file="$1" shift mkdir -p "$(dirname "$report_file")" cat > "$report_file" } cleanup_compose() { local compose_file="$1" if [ -f "$compose_file" ]; then docker compose -f "$compose_file" down >/dev/null 2>&1 || true fi } # Hilfsfunktion: bei Fehler-Exit Restore-Lab-Pfad nicht loeschen, sondern in # einen `_failed/--`-Pfad umbenennen, damit Post-Mortem # moeglich bleibt. Aufrufer setzt vor Erfolg `RESTORE_SUCCESS=1`. RESTORE_FAILED_ROOT="${RESTORE_FAILED_ROOT:-/mnt/user/backups/restore-lab/_failed}" preserve_on_failure() { local service="$1" local path="$2" if [ ! -e "$path" ]; then return 0 fi mkdir -p "$RESTORE_FAILED_ROOT" local target="$RESTORE_FAILED_ROOT/${service}-$(date +%F)-$$" if mv "$path" "$target" 2>/dev/null; then echo "preserved failed restore data: $target" >&2 else echo "failed to preserve restore data: $path -> $target" >&2 fi }