feat(backup): add enterprise backup & restore foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 17:13:31 +02:00
parent 7835d3ca75
commit 6c917df515
30 changed files with 1538 additions and 61 deletions

161
scripts/restore.sh Executable file → Normal file
View file

@ -1,46 +1,151 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
BACKUP_SOURCE="${1:-}"
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
DATABASE_URL="${DATABASE_URL:-}"
STOPPED_SERVICES=0
if [[ -z "${BACKUP_SOURCE}" || ! -d "${BACKUP_SOURCE}" ]]; then
echo "Usage: scripts/restore.sh <backup-directory>" >&2
if [[ -z "$BACKUP_SOURCE" || ! -f "$BACKUP_SOURCE" ]]; then
echo "Usage: scripts/restore.sh <backup-zip>" >&2
exit 1
fi
echo "This restore can overwrite database and storage state."
echo "Backup source: ${BACKUP_SOURCE}"
echo "Storage target: ${STORAGE_HOST_PATH}"
read -r -p "Type RESTORE to continue: " confirmation
if [[ -z "$DATABASE_URL" ]]; then
echo "DATABASE_URL ist erforderlich." >&2
exit 1
fi
if [[ "${confirmation}" != "RESTORE" ]]; then
echo "Restore cancelled"
if ! command -v pg_restore >/dev/null 2>&1; then
echo "pg_restore ist nicht verfuegbar." >&2
exit 1
fi
mkdir -p "$STORAGE_HOST_PATH" "$BACKUP_DIR"
python3 - "$BACKUP_SOURCE" <<'PY'
from pathlib import Path
import sys
import zipfile
path = Path(sys.argv[1]).resolve()
required = {"manifest.json", "database.dump"}
try:
with zipfile.ZipFile(path) as archive:
names = set(archive.namelist())
except zipfile.BadZipFile as exc:
raise SystemExit(f"Ungueltige ZIP-Datei: {exc}") from exc
missing = [name for name in required if name not in names]
if not any(name == "storage/" or name.startswith("storage/") for name in names):
missing.append("storage/")
if missing:
raise SystemExit(f"Backup unvollstaendig: {', '.join(missing)}")
PY
echo "WARNUNG: Dieser Restore kann Datenbank- und Storage-Daten dauerhaft ueberschreiben."
echo "Backup: $BACKUP_SOURCE"
read -r -p "Bitte exakt 'ICH VERSTEHE DAS RISIKO' eingeben: " confirmation
if [[ "$confirmation" != "ICH VERSTEHE DAS RISIKO" ]]; then
echo "Restore abgebrochen."
exit 0
fi
if [[ -f "${BACKUP_SOURCE}/postgres.sql" ]]; then
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
echo "==> Restoring PostgreSQL dump into container ${POSTGRES_CONTAINER}"
docker exec -i "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" < "${BACKUP_SOURCE}/postgres.sql"
elif command -v psql >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
echo "==> Restoring PostgreSQL dump from DATABASE_URL"
psql "${DATABASE_URL}" < "${BACKUP_SOURCE}/postgres.sql"
else
echo "WARN: PostgreSQL restore skipped. Set POSTGRES_CONTAINER or install psql with DATABASE_URL."
if command -v docker >/dev/null 2>&1; then
read -r -p "Olympus-Dienste jetzt per docker compose stoppen? [y/N] " stop_reply
if [[ "$stop_reply" =~ ^[Yy]$ ]]; then
docker compose stop athena hermes
STOPPED_SERVICES=1
fi
else
echo "WARN: postgres.sql not found; database restore skipped."
fi
if [[ -f "${BACKUP_SOURCE}/storage.tar.gz" ]]; then
mkdir -p "${STORAGE_HOST_PATH}"
echo "==> Restoring storage archive"
tar -xzf "${BACKUP_SOURCE}/storage.tar.gz" -C "${STORAGE_HOST_PATH}"
else
echo "WARN: storage.tar.gz not found; storage restore skipped."
fi
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.restore.XXXXXX")"
trap 'rm -rf "$WORK_DIR"; if [[ "$STOPPED_SERVICES" -eq 1 ]]; then docker compose start hermes athena; fi' EXIT
echo "==> Restore completed"
python3 - "$BACKUP_SOURCE" "$WORK_DIR" <<'PY'
from pathlib import Path
import sys
import zipfile
archive_path = Path(sys.argv[1]).resolve()
target_dir = Path(sys.argv[2]).resolve()
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(target_dir)
PY
SNAPSHOT_PATH="${BACKUP_DIR%/}/pre-restore-storage-$(date +%Y%m%d-%H%M%S).tar.gz"
export STORAGE_HOST_PATH SNAPSHOT_PATH
python3 - <<'PY'
from pathlib import Path
import os
import tarfile
storage = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
snapshot = Path(os.environ["SNAPSHOT_PATH"]).resolve()
storage.mkdir(parents=True, exist_ok=True)
with tarfile.open(snapshot, "w:gz") as archive:
for path in sorted(storage.rglob("*")):
if path.name == "backups" and path.is_dir():
continue
if "backups" in path.parts:
continue
archive.add(path, arcname=path.relative_to(storage))
PY
PG_RESTORE_URL="$(python3 - <<'PY'
import os
from urllib.parse import urlsplit, urlunsplit
database_url = os.environ["DATABASE_URL"]
parts = urlsplit(database_url)
scheme = parts.scheme.split("+", 1)[0]
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
PY
)"
pg_restore \
--clean \
--if-exists \
--no-owner \
--no-privileges \
--dbname="$PG_RESTORE_URL" \
"$WORK_DIR/database.dump"
export WORK_DIR
python3 - <<'PY'
from pathlib import Path
import os
import shutil
storage_target = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
storage_source = (Path(os.environ["WORK_DIR"]).resolve() / "storage")
storage_target.mkdir(parents=True, exist_ok=True)
for path in storage_target.iterdir():
if path.name == "backups":
continue
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
for source_path in sorted(storage_source.rglob("*")):
relative = source_path.relative_to(storage_source)
destination = storage_target / relative
if source_path.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, destination)
PY
echo "Restore abgeschlossen. Storage-Snapshot: ${SNAPSHOT_PATH}"