#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}" BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}" APP_VERSION="${APP_VERSION:-0.9.1}" DATABASE_URL="${DATABASE_URL:-}" if [[ -z "$DATABASE_URL" ]]; then echo "DATABASE_URL ist erforderlich." >&2 exit 1 fi if ! command -v pg_dump >/dev/null 2>&1; then echo "pg_dump ist nicht verfuegbar." >&2 exit 1 fi mkdir -p "$BACKUP_DIR" "$STORAGE_HOST_PATH" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" FILENAME="olympus-backup-${TIMESTAMP}.zip" WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")" trap 'rm -rf "$WORK_DIR"' EXIT export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME python3 - <<'PY' from pathlib import Path import os import shutil source = Path(os.environ["STORAGE_HOST_PATH"]).resolve() backup_dir = Path(os.environ["BACKUP_DIR"]).resolve() target = Path(os.environ["WORK_DIR"]).resolve() / "storage" source.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True) for source_path in sorted(source.rglob("*")): if source_path == backup_dir or backup_dir in source_path.parents: continue relative = source_path.relative_to(source) destination = target / relative if source_path.is_dir(): destination.mkdir(parents=True, exist_ok=True) continue if source_path.is_file(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_path, destination) PY PG_DUMP_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_dump \ --format=custom \ --no-owner \ --no-privileges \ --file="$WORK_DIR/database.dump" \ --dbname="$PG_DUMP_URL" python3 - <<'PY' from datetime import datetime, UTC from hashlib import sha256 from pathlib import Path import json import os from urllib.parse import urlsplit import zipfile work_dir = Path(os.environ["WORK_DIR"]).resolve() storage_dir = work_dir / "storage" dump_path = work_dir / "database.dump" backup_dir = Path(os.environ["BACKUP_DIR"]).resolve() filename = os.environ["FILENAME"] archive_path = backup_dir / filename database_url = urlsplit(os.environ["DATABASE_URL"]) def update_digest_from_file(digest, file_path: Path) -> None: with file_path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) checksum = sha256() checksum.update(b"database.dump") update_digest_from_file(checksum, dump_path) file_count = 1 total_size = dump_path.stat().st_size for file_path in sorted(storage_dir.rglob("*")): if file_path.is_dir(): continue checksum.update(file_path.relative_to(work_dir).as_posix().encode("utf-8")) update_digest_from_file(checksum, file_path) file_count += 1 total_size += file_path.stat().st_size host = database_url.hostname or "unknown" manifest = { "backup_id": sha256(f"{filename}:{datetime.now(UTC).isoformat()}".encode("utf-8")).hexdigest()[:24], "created_at": datetime.now(UTC).isoformat(), "app_version": os.environ["APP_VERSION"], "backup_type": "full", "database_url_host_anonymized": f"sha256:{sha256(host.encode('utf-8')).hexdigest()[:12]}", "database_name": database_url.path.rsplit("/", 1)[-1] or "unknown", "storage_base_path": os.environ["STORAGE_HOST_PATH"], "included_sections": ["database", "storage"], "file_count": file_count, "total_size_bytes": total_size, "checksum_sha256": checksum.hexdigest(), "created_by_user_id": None, "created_by_username": "cli", } manifest_path = work_dir / "manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=True), encoding="utf-8") with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("storage/", "") archive.write(manifest_path, "manifest.json") archive.write(dump_path, "database.dump") for file_path in sorted(storage_dir.rglob("*")): if file_path.is_dir(): continue archive.write(file_path, file_path.relative_to(work_dir).as_posix()) PY echo "Backup erstellt: ${BACKUP_DIR%/}/${FILENAME}"