#!/bin/bash set -euo pipefail # Nextcloud Restore Smoke Test # # Nicht-destruktiver Restore-Smoke-Test fuer Nextcloud. # # Was dieser Smoke nachweist: # - Nextcloud-HTML und -Datenpfade koennen aus dem Borg-Archiv extrahiert werden # - nextcloud.dump kann in eine isolierte Test-Postgres importiert werden # - Nextcloud startet gegen die restaurierten Daten + Test-Redis und antwortet # auf HTTP # - occ status zeigt maintenance:mode = false # # Besonderheiten gegenueber den anderen Restore-Tests: # - Nextcloud hat eine eigene Postgres (nicht shared), mit eigener DB-Rolle # - Nextcloud nutzt eine eigene Redis-Instanz (Snapshot-Persistenz, kein Passwort) # - occ maintenance:mode und die Rolle oc_admin sind im DR-Fall relevant; # im Smoke pruefen wir occ status nach dem Boot # - Produktive Secrets (admin_user, admin_password, postgres_password) werden # durch Wegwerf-Werte im Test-Compose ersetzt # # Produktive Nextcloud-Container, produktive Postgres-DB, produktive Secrets, # produktive Nutzdaten unter /mnt/user/documents/nextcloud-data und # produktiver Traefik-Eintrag werden NICHT angefasst. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/common.sh" WHATIF=0 KEEP_DATA=0 for arg in "$@"; do case "$arg" in --what-if) WHATIF=1 ;; --keep-data) KEEP_DATA=1 ;; *) echo "Unknown argument: $arg" >&2; exit 1 ;; esac done RESTORE_ROOT="/mnt/user/backups/restore-lab/nextcloud" REPORT_ROOT="/mnt/user/backups/restore-reports" EXTRACT_DIR="$BORG_RESTORE_HOST_ROOT/nextcloud-extract" COMPOSE_FILE="$SCRIPT_DIR/nextcloud-compose.test.yml" REPORT_FILE="$REPORT_ROOT/nextcloud-$(date +%F).md" if [ "$WHATIF" -eq 1 ]; then cat < nextcloud.dump - HTTP 200/302/3xx von 127.0.0.1:18180 - occ status: maintenance=false EOF exit 0 fi require_cmd docker require_cmd curl require_path "$BORG_PASSPHRASE_FILE_DEFAULT" require_path "$COMPOSE_FILE" RESTORE_SUCCESS=0 cleanup() { cleanup_compose "$COMPOSE_FILE" if [ "$RESTORE_SUCCESS" -ne 1 ]; then preserve_on_failure "nextcloud" "$RESTORE_ROOT" rm -rf "$EXTRACT_DIR" return fi if [ "$KEEP_DATA" -ne 1 ]; then rm -rf "$RESTORE_ROOT" fi rm -rf "$EXTRACT_DIR" } trap cleanup EXIT rm -rf "$EXTRACT_DIR" "$RESTORE_ROOT" mkdir -p "$RESTORE_ROOT/html" "$RESTORE_ROOT/data" "$RESTORE_ROOT/postgres" "$RESTORE_ROOT/dumps/latest" archive="$(latest_archive_name)" repo="$(borg_repo_url)" if [ -z "$archive" ] || [ -z "$repo" ]; then echo "Could not resolve Borg repo/archive from borg-ui database" >&2 exit 1 fi # Stufe 1: Nextcloud-App-Pfade aus Borg, Dump vom Host. # HTML (App-Code + config) kommt aus dem Borg-Archiv. # Der Dump liegt frisch auf dem Host unter /mnt/user/backups/borg/dumps/latest/ # (wird taeglich von pre-backup-dumps.sh erzeugt und dann in Borg gesichert). # Der Borg-Extract des Dumps wuerde dieselbe Datei liefern, braucht aber eine # eigene Remote-Roundtrip-Zeit; wir nutzen die Host-Kopie direkt. DUMP_HOST_PATH="/mnt/user/backups/borg/dumps/latest/nextcloud.dump" borg_extract "/restore/nextcloud-extract" \ "local/appdata/nextcloud/html" if [ ! -d "$EXTRACT_DIR/local/appdata/nextcloud/html" ]; then echo "Nextcloud html path missing in Borg archive" >&2 exit 1 fi if [ ! -f "$DUMP_HOST_PATH" ]; then echo "nextcloud.dump missing on host at $DUMP_HOST_PATH" >&2 exit 1 fi # App-Code + Config ins Restore-Lab verschieben cp -a "$EXTRACT_DIR/local/appdata/nextcloud/html/." "$RESTORE_ROOT/html/" cp "$DUMP_HOST_PATH" "$RESTORE_ROOT/dumps/latest/nextcloud.dump" # Nextcloud braucht einen beschreibbaren data-Pfad, auch wenn er leer ist. # Im Restore-Lab ist das /mnt/user/backups/restore-lab/nextcloud/data. mkdir -p "$RESTORE_ROOT/data" # Unraid (FUSE/shfs) ignoriert chown auf User-Shares. Stattdessen setzen # wir die Dateien auf world-writable, damit der Nextcloud-Entrypoint # (der als root startet und intern auf www-data wechselt) die Dateien # lesen und beschreiben kann. Im isolierten Smoke-Kontext vertretbar. chmod -R a+rwX "$RESTORE_ROOT/html" "$RESTORE_ROOT/data" # Falls config.php einen anderen dbuser als das Test-Compose hat, patchen # wir die DB-Zugangsdaten in der restaurierten config.php fuer den Test. CONFIG_PHP="$RESTORE_ROOT/html/config/config.php" if [ -f "$CONFIG_PHP" ]; then # Backup der Originalkonfig fuer Diagnose cp "$CONFIG_PHP" "$RESTORE_ROOT/html/config/config.php.original" # DB-Credentials auf die Test-Werte umbiegen. Nextcloud config.php # ist PHP; wir patchen die relevanten Zeilen per sed. sed -i \ -e "s|'dbhost'.*|'dbhost' => 'restoretest-nextcloud-postgres',|" \ -e "s|'dbuser'.*|'dbuser' => 'nextcloud',|" \ -e "s|'dbpassword'.*|'dbpassword' => 'restoretest-nextcloud-db',|" \ -e "s|'dbname'.*|'dbname' => 'nextcloud',|" \ -e "s|'dbport'.*|'dbport' => '',|" \ "$CONFIG_PHP" # Redis-Host patchen. Die config.php hat ein verschachteltes Array: # 'redis' => array( 'host' => 'nextcloud-redis', ... ) # Wir ersetzen nur den Host-Wert innerhalb des redis-Blocks. sed -i "s|'host' => 'nextcloud-redis'|'host' => 'restoretest-nextcloud-redis'|g" "$CONFIG_PHP" # Zwei Patches in der config.php, beides per PHP-Code-Injection am Ende: # # 1. trusted_domains: 127.0.0.1 hinzufuegen, damit der Smoke-Endpunkt # akzeptiert wird. Nextcloud prueft trusted_domains und blockt sonst # mit "Access through untrusted domain" (503). # # 2. check_data_directory_permissions: false. Hintergrund: Nextcloud # (OC_Util::checkDataDirectoryPermissions) prueft beim HTTP-Request, ob # die data-Dir-Permissions in der letzten Stelle 0 sind. Falls nicht, # versucht es als www-data ein chmod(0770). Auf Unraid (shfs/FUSE) # lehnt das Filesystem chmod von Non-Root ab, also kann der Container # das nie korrigieren -> Nextcloud meldet "data directory readable by # other people" -> HTTP 503. Im isolierten Smoke-Kontext (Wegwerf- # Daten, kein Public, kein Traefik) ist das Aushebeln dieses Checks # sauber dokumentiert vorgesehen. Produktiv bleibt der Check an. php -r " \$f = '$CONFIG_PHP'; \$c = file_get_contents(\$f); if (strpos(\$c, \"'127.0.0.1'\") === false || strpos(\$c, 'check_data_directory_permissions') === false) { include \$f; if (!in_array('127.0.0.1', \$CONFIG['trusted_domains'])) { \$CONFIG['trusted_domains'][] = '127.0.0.1'; } \$CONFIG['check_data_directory_permissions'] = false; \$out = '/dev/null || { # Fallback: wenn php nicht auf dem Host ist, per sed versuchen if ! grep -q "127.0.0.1" "$CONFIG_PHP"; then sed -i "/'trusted_domains'/,/^ )/s|^ )| 99 => '127.0.0.1',\n )|" "$CONFIG_PHP" || true fi if ! grep -q "check_data_directory_permissions" "$CONFIG_PHP"; then sed -i "s|^);| 'check_data_directory_permissions' => false,\n);|" "$CONFIG_PHP" || true fi } config_patched="ok" else config_patched="no config.php found" fi # Stufe 2: Test-Postgres + Test-Redis hochfahren docker compose -f "$COMPOSE_FILE" up -d restoretest-nextcloud-postgres restoretest-nextcloud-redis >/dev/null until docker exec restoretest-nextcloud-postgres pg_isready -U nextcloud -d nextcloud >/dev/null 2>&1; do sleep 2 done # Stufe 3: Dump einspielen (mit Retry wie bei Paperless/Immich) restore_ok=0 for attempt in $(seq 1 12); do if docker exec -i restoretest-nextcloud-postgres \ pg_restore -U nextcloud -d nextcloud --clean --if-exists --no-owner --no-privileges \ < "$RESTORE_ROOT/dumps/latest/nextcloud.dump" 2>/tmp/nextcloud-pg-restore.err; then restore_ok=1 break fi if grep -qiE "starting up|shutting down|connection refused|database .* does not exist" /tmp/nextcloud-pg-restore.err; then sleep 5 continue fi # pg_restore mit --clean erzeugt "does not exist"-Warnungen fuer nicht vorhandene # Objekte beim ersten Import. Diese sind erwartbar und kein echter Fehler. # Wir pruefen auf harte Fehler. if grep -qiE "FATAL|PANIC" /tmp/nextcloud-pg-restore.err; then cat /tmp/nextcloud-pg-restore.err >&2 exit 1 fi restore_ok=1 break done if [ "$restore_ok" -ne 1 ]; then cat /tmp/nextcloud-pg-restore.err >&2 exit 1 fi # Stufe 4: Nextcloud starten docker compose -f "$COMPOSE_FILE" up -d restoretest-nextcloud >/dev/null # Nextcloud braucht beim ersten Start mit existierender config.php einige # Sekunden fuer DB-Migrations-Checks. Wir geben bis zu 180s. http_status="" for _ in $(seq 1 90); do http_status="$(curl -s -o /tmp/nextcloud-body.html -w '%{http_code}' \ -L http://127.0.0.1:18180/status.php || true)" if [ "$http_status" = "200" ]; then break fi sleep 2 done if [ "$http_status" != "200" ]; then echo "Nextcloud HTTP smoke failed: status=$http_status" >&2 docker logs --tail 120 restoretest-nextcloud >&2 || true exit 1 fi # Stufe 5: occ status pruefen (maintenance mode) occ_output="$(docker exec -u www-data restoretest-nextcloud php occ status --output=json 2>/dev/null || echo '{}')" maintenance="$(echo "$occ_output" | grep -o '"maintenance":[a-z]*' | head -1 | cut -d: -f2)" if [ -z "$maintenance" ]; then maintenance="unknown" fi # DB-Tabellen-Count als fachlicher Sanity-Check table_count="$(docker exec restoretest-nextcloud-postgres \ psql -U nextcloud -d nextcloud -tAc \ "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" \ 2>/dev/null | tr -d '[:space:]' || echo "n/a")" write_report "$REPORT_FILE" < $REPORT_FILE"