diff --git a/.env.example b/.env.example index 77728fe..155c54e 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ STORAGE_PROVIDER=local STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage +# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume. # Legacy-Fallback fuer bestehende Knowledge-Installationen. KNOWLEDGE_STORAGE_PATH=/data/knowledge diff --git a/.gitignore b/.gitignore index b36a9d7..5bd760f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ out/ # Runtime storage # =========================== storage/ +/backups/ +*.dump # =========================== # Coverage diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 62a0499..3bced00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1060,9 +1060,29 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment: `deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`. -`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden. +`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`. -`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`. +`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck. + +### Backup-Modul ab v0.9.1 + +Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul: + +- Hermes-Service: `backend/hermes/app/services/backup_service.py` +- Hermes-API: `backend/hermes/app/api/backups.py` +- Hermes-Schemas: `backend/hermes/app/schemas/backup.py` +- Athena-Seite: `frontend/athena/app/backups/page.tsx` +- Athena-BFF: `/api/backups/...` + +Sicherheitsregeln: + +- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`. +- Der Backup-Ordner ist Teil des persistenten Storage-Volumes. +- Hermes anonymisiert den Datenbank-Host im Manifest. +- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt. +- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`. + +Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`. ### Knowledge-RBAC diff --git a/README-DEV.md b/README-DEV.md index 3aee4d1..d309d74 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -134,6 +134,46 @@ STORAGE_BASE_PATH=/data/storage Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen. +## Backup und Restore + +Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul. + +Ablage: + +- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups` +- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups` +- Backup-Dateien gehoeren nie ins Git + +Inhalt eines Backups: + +- `manifest.json` +- `database.dump` +- `storage/` + +Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit: + +- `GET /api/backups` +- `POST /api/backups/create` +- `GET /api/backups/[filename]/download` +- `POST /api/backups/[filename]/validate` +- `POST /api/backups/[filename]/restore` +- `DELETE /api/backups/[filename]/delete` + +Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein. + +Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt: + +1. Backup validieren. +2. Sicherheitsbestaetigung pruefen. +3. Restore ueber `scripts/restore.sh ` ausfuehren. +4. Ergebnis und Audit Logs kontrollieren. + +Empfehlung fuer den Betrieb: + +- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern. +- Backup-Dateien vor Offsite-Kopie verschluesseln. +- Restore nur in Wartungsfenstern ausfuehren. + ## Knowledge Workflow Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf: diff --git a/ROADMAP.md b/ROADMAP.md index 040120d..7c0efb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,13 +201,24 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Exportstatus und Fehlerbehebung im Olympus UI erweitern - Optionaler Download/Link zur Lexware-Rechnung -## v0.9.1 - Kundenportal, geplant +## v0.9.1 - Backup und Restore + +- Backup-Modul mit Athena-BFF und Hermes-Service-Layer +- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/` +- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups` +- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore` +- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus +- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung +- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh` +- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad + +## v0.9.2 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden - Keine Vermischung mit internen Olympus-Benutzern -## v0.9.2 - Tickets, geplant +## v0.9.3 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index 8349be7..5704b10 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -66,6 +66,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: return "lexware.read" in permissions if action.startswith("accounting."): return "lexware.read" in permissions + if action.startswith("backups."): + return "backup.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/backups.py b/backend/hermes/app/api/backups.py new file mode 100644 index 0000000..75e131e --- /dev/null +++ b/backend/hermes/app/api/backups.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.user import User +from app.schemas.api_response import ApiSuccess +from app.schemas.backup import BackupRestoreRequest +from app.services.audit_service import write_audit_log +from app.services.backup_service import BackupService + +router = APIRouter(prefix="/backups", tags=["Backups"]) + + +@router.get("", response_model=ApiSuccess) +def list_backups( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.read")), +): + return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen") + + +@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED) +def create_backup( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.create")), +): + backup = BackupService.create_backup(actor=current_user) + write_audit_log( + db, + action="backups.create", + entity_type="backup", + entity_label=backup.filename, + actor=current_user, + request=request, + metadata={ + "filename": backup.filename, + "size_bytes": backup.size_bytes, + "app_version": backup.app_version, + }, + ) + return ApiSuccess(data=backup, message="Backup erstellt") + + +@router.get("/{filename}/download") +def download_backup( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.download")), +): + path = BackupService.resolve_backup_path(filename) + write_audit_log( + db, + action="backups.download", + entity_type="backup", + entity_label=path.name, + actor=current_user, + request=request, + metadata={"filename": path.name, "size_bytes": path.stat().st_size}, + ) + return FileResponse(path=path, media_type="application/zip", filename=path.name) + + +@router.delete("/{filename}", response_model=ApiSuccess) +def delete_backup( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.delete")), +): + path = BackupService.resolve_backup_path(filename) + size_bytes = path.stat().st_size + BackupService.delete_backup(filename) + write_audit_log( + db, + action="backups.delete", + entity_type="backup", + entity_label=path.name, + actor=current_user, + request=request, + metadata={"filename": path.name, "size_bytes": size_bytes}, + ) + return ApiSuccess(message="Backup geloescht") + + +@router.post("/{filename}/restore/validate", response_model=ApiSuccess) +def validate_backup_restore( + filename: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.restore")), +): + validation = BackupService.validate_backup(filename) + write_audit_log( + db, + action="backups.validate", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues}, + ) + return ApiSuccess(data=validation, message=validation.message) + + +@router.post("/{filename}/restore", response_model=ApiSuccess) +def restore_backup( + filename: str, + payload: BackupRestoreRequest, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("backup.restore")), +): + write_audit_log( + db, + action="backups.restore_started", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + try: + validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text) + except Exception: + write_audit_log( + db, + action="backups.restore_failed", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + raise + + write_audit_log( + db, + action="backups.restore_completed", + entity_type="backup", + entity_label=filename, + actor=current_user, + request=request, + metadata={"filename": filename}, + ) + return ApiSuccess(data=validation, message="Restore abgeschlossen") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 7488a6d..02d2752 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.core.config import settings from app.core.rbac import get_user_permission_names, require_permission from app.db.database import get_db from app.models.rbac import Role @@ -16,6 +17,7 @@ from app.repositories.repair_repository import RepairRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.user_repository import UserRepository from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem +from app.services.backup_service import BackupService from app.services.system_settings_service import SystemSettingsService logger = logging.getLogger(__name__) @@ -162,6 +164,27 @@ def get_dashboard_summary( ), ] + if "backup.read" in permissions: + backup_stats = BackupService.get_backup_stats() + repairs.append(MetricCard(label="Backups", value=backup_stats.total_count)) + system_status.extend([ + SystemStatusItem( + label="Letztes Backup", + value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup", + status="ok" if backup_stats.latest_backup_at else "warning", + ), + SystemStatusItem( + label="Backup-Speicher", + value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes", + status="ok" if backup_stats.total_count else "warning", + ), + SystemStatusItem( + label="Hermes-Version", + value=settings.app_version, + status="info", + ), + ]) + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) return DashboardSummary( diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 23c8c71..2a3d97f 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): secret_key: str app_name: str = "Hermes API" - app_version: str = "0.1.0" + app_version: str = "0.9.1" access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" log_level: str = "INFO" diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index ffb31e2..04c84ca 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -11,6 +11,7 @@ from starlette.requests import Request from app.api.auth import router as auth_router from app.api.audit import router as audit_router +from app.api.backups import router as backups_router from app.api.customers import router as customers_router from app.api.dashboard import router as dashboard_router from app.api.inventory import router as inventory_router @@ -33,12 +34,13 @@ configure_logging() app = FastAPI( title="Hermes API", - version="0.1.0", + version="0.9.1", description="Backend von Olympus", ) app.include_router(auth_router) app.include_router(audit_router) +app.include_router(backups_router) app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 9974a8b..6661bff 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -102,6 +102,11 @@ STANDARD_PERMISSIONS = [ ("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"), ("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"), ("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"), + ("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"), + ("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"), + ("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"), + ("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"), + ("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"), ] ROLE_PERMISSION_NAMES = { @@ -134,6 +139,9 @@ ROLE_PERMISSION_NAMES = { "lexware.read", "lexware.manage", "lexware.export", + "backup.read", + "backup.create", + "backup.download", }, "sales": { "dashboard.read", diff --git a/backend/hermes/app/schemas/backup.py b/backend/hermes/app/schemas/backup.py new file mode 100644 index 0000000..661d1e9 --- /dev/null +++ b/backend/hermes/app/schemas/backup.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class BackupManifest(BaseModel): + backup_id: str + created_at: datetime + app_version: str + backup_type: str = "full" + database_url_host_anonymized: str + database_name: str + storage_base_path: str + included_sections: list[str] = Field(default_factory=list) + file_count: int = 0 + total_size_bytes: int = 0 + checksum_sha256: str + created_by_user_id: int | None = None + created_by_username: str = "" + + +class BackupSummary(BaseModel): + filename: str + size_bytes: int + created_at: datetime | None = None + app_version: str = "" + backup_type: str = "full" + database_name: str = "" + storage_base_path: str = "" + file_count: int = 0 + total_size_bytes: int = 0 + created_by_user_id: int | None = None + created_by_username: str = "" + validation_status: str = "valid" + validation_message: str = "" + + +class BackupListResponse(BaseModel): + items: list[BackupSummary] = Field(default_factory=list) + total_count: int = 0 + total_size_bytes: int = 0 + latest_backup_at: datetime | None = None + + +class BackupValidationResponse(BaseModel): + filename: str + valid: bool + message: str + issues: list[str] = Field(default_factory=list) + checksum_valid: bool = False + restore_supported: bool = False + requires_cli_restore: bool = True + manifest: BackupManifest | None = None + + +class BackupRestoreRequest(BaseModel): + confirm_text: str diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 12518a6..14110e0 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -215,6 +215,13 @@ def action_title(action: str) -> str: "accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben", "accounting.invoice.mark_transferred": "Rechnung als übertragen markiert", "accounting.invoice.note_update": "Buchhaltungsnotiz geändert", + "backups.create": "Backup erstellt", + "backups.download": "Backup heruntergeladen", + "backups.delete": "Backup gelöscht", + "backups.validate": "Backup validiert", + "backups.restore_started": "Restore gestartet", + "backups.restore_failed": "Restore fehlgeschlagen", + "backups.restore_completed": "Restore abgeschlossen", } return labels.get(action, action) diff --git a/backend/hermes/app/services/backup_service.py b/backend/hermes/app/services/backup_service.py new file mode 100644 index 0000000..b84ffe2 --- /dev/null +++ b/backend/hermes/app/services/backup_service.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile + +from fastapi import HTTPException, status +from sqlalchemy.engine import make_url + +from app.core.config import settings +from app.models.user import User +from app.schemas.backup import ( + BackupListResponse, + BackupManifest, + BackupSummary, + BackupValidationResponse, +) + +BACKUP_FILENAME_PREFIX = "olympus-backup-" +BACKUP_FILENAME_SUFFIX = ".zip" +DATABASE_DUMP_NAME = "database.dump" +MANIFEST_NAME = "manifest.json" +STORAGE_DIR_NAME = "storage" +BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO" +RESTORE_DISABLED_MESSAGE = ( + "Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. " + "Bitte Restore ueber CLI-Script ausfuehren." +) + + +@dataclass(frozen=True) +class BackupStats: + total_count: int + total_size_bytes: int + latest_backup_at: datetime | None + + +class BackupService: + @staticmethod + def get_backup_dir() -> Path: + backup_dir = (Path(settings.storage_base_path) / "backups").resolve() + backup_dir.mkdir(parents=True, exist_ok=True) + return backup_dir + + @staticmethod + def list_backups() -> BackupListResponse: + items = [ + BackupService._read_summary(path) + for path in sorted( + BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + ] + latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None) + return BackupListResponse( + items=items, + total_count=len(items), + total_size_bytes=sum(item.size_bytes for item in items), + latest_backup_at=latest_backup_at, + ) + + @staticmethod + def get_backup_stats() -> BackupStats: + backups = BackupService.list_backups() + return BackupStats( + total_count=backups.total_count, + total_size_bytes=backups.total_size_bytes, + latest_backup_at=backups.latest_backup_at, + ) + + @staticmethod + def create_backup(*, actor: User) -> BackupSummary: + backup_dir = BackupService.get_backup_dir() + timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}" + target_path = backup_dir / filename + + with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name: + temp_dir = Path(temp_dir_name) + dump_path = temp_dir / DATABASE_DUMP_NAME + storage_temp_dir = temp_dir / STORAGE_DIR_NAME + manifest_path = temp_dir / MANIFEST_NAME + archive_path = temp_dir / filename + + BackupService._run_pg_dump(dump_path) + file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir) + dump_size = dump_path.stat().st_size + checksum_sha256 = BackupService._calculate_archive_checksum( + dump_path=dump_path, + storage_dir=storage_temp_dir, + ) + + manifest = BackupManifest( + backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24], + created_at=datetime.now(UTC), + app_version=settings.app_version, + backup_type="full", + database_url_host_anonymized=BackupService._anonymized_database_host(), + database_name=BackupService._database_name(), + storage_base_path=settings.storage_base_path, + included_sections=["database", "storage"], + file_count=file_count + 1, + total_size_bytes=total_size_bytes + dump_size, + checksum_sha256=checksum_sha256, + created_by_user_id=actor.id, + created_by_username=actor.username, + ) + manifest_path.write_text( + json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True), + encoding="utf-8", + ) + + BackupService._write_archive( + archive_path=archive_path, + manifest_path=manifest_path, + dump_path=dump_path, + storage_dir=storage_temp_dir, + ) + shutil.move(str(archive_path), target_path) + + return BackupService._read_summary(target_path) + + @staticmethod + def validate_backup(filename: str) -> BackupValidationResponse: + path = BackupService.resolve_backup_path(filename) + issues: list[str] = [] + manifest: BackupManifest | None = None + checksum_valid = False + + try: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if MANIFEST_NAME not in names: + issues.append("manifest.json fehlt") + if DATABASE_DUMP_NAME not in names: + issues.append("database.dump fehlt") + if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names): + issues.append("storage/ fehlt") + + if MANIFEST_NAME in names: + try: + with archive.open(MANIFEST_NAME) as manifest_file: + manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8")) + except Exception: + issues.append("manifest.json ist ungueltig") + + if manifest is not None: + checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256) + if not checksum_valid: + issues.append("Checksumme ist ungueltig") + except zipfile.BadZipFile: + issues.append("ZIP-Datei ist ungueltig") + + valid = len(issues) == 0 + return BackupValidationResponse( + filename=path.name, + valid=valid, + message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen", + issues=issues, + checksum_valid=checksum_valid, + restore_supported=False, + requires_cli_restore=True, + manifest=manifest, + ) + + @staticmethod + def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse: + if confirm_text != BACKUP_CONFIRM_TEXT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Bestaetigungstext stimmt nicht ueberein", + ) + + validation = BackupService.validate_backup(filename) + if not validation.valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Backup ist ungueltig und kann nicht wiederhergestellt werden", + ) + + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail=RESTORE_DISABLED_MESSAGE, + ) + + @staticmethod + def delete_backup(filename: str) -> None: + path = BackupService.resolve_backup_path(filename) + path.unlink(missing_ok=False) + + @staticmethod + def resolve_backup_path(filename: str) -> Path: + if not filename.endswith(BACKUP_FILENAME_SUFFIX): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + if Path(filename).name != filename or ".." in Path(filename).parts: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + + path = (BackupService.get_backup_dir() / filename).resolve() + backup_dir = BackupService.get_backup_dir() + if backup_dir != path.parent or not path.exists() or not path.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden") + return path + + @staticmethod + def _run_pg_dump(dump_path: Path) -> None: + pg_dump_url = BackupService._pg_dump_database_url() + command = [ + "pg_dump", + "--format=custom", + "--no-owner", + "--no-privileges", + f"--file={dump_path}", + f"--dbname={pg_dump_url}", + ] + + try: + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="pg_dump ist im Hermes-Container nicht verfuegbar", + ) from exc + except subprocess.CalledProcessError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="PostgreSQL-Dump konnte nicht erstellt werden", + ) from exc + + @staticmethod + def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]: + source_dir = Path(settings.storage_base_path).resolve() + backup_dir = BackupService.get_backup_dir() + source_dir.mkdir(parents=True, exist_ok=True) + target_dir.mkdir(parents=True, exist_ok=True) + file_count = 0 + total_size_bytes = 0 + + for source_path in sorted(source_dir.rglob("*")): + if source_path == backup_dir or backup_dir in source_path.parents: + continue + relative_path = source_path.relative_to(source_dir) + destination_path = target_dir / relative_path + if source_path.is_dir(): + destination_path.mkdir(parents=True, exist_ok=True) + continue + if not source_path.is_file(): + continue + destination_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, destination_path) + file_count += 1 + total_size_bytes += source_path.stat().st_size + + return file_count, total_size_bytes + + @staticmethod + def _write_archive( + *, + archive_path: Path, + manifest_path: Path, + dump_path: Path, + storage_dir: Path, + ) -> None: + with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(f"{STORAGE_DIR_NAME}/", "") + archive.write(manifest_path, MANIFEST_NAME) + archive.write(dump_path, DATABASE_DUMP_NAME) + for file_path in sorted(storage_dir.rglob("*")): + if file_path.is_dir(): + continue + archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix()) + + @staticmethod + def _read_summary(path: Path) -> BackupSummary: + default_summary = BackupSummary( + filename=path.name, + size_bytes=path.stat().st_size, + validation_status="warning", + validation_message="Manifest konnte nicht gelesen werden", + ) + try: + with zipfile.ZipFile(path) as archive: + with archive.open(MANIFEST_NAME) as manifest_file: + manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8")) + return BackupSummary( + filename=path.name, + size_bytes=path.stat().st_size, + created_at=manifest.created_at, + app_version=manifest.app_version, + backup_type=manifest.backup_type, + database_name=manifest.database_name, + storage_base_path=manifest.storage_base_path, + file_count=manifest.file_count, + total_size_bytes=manifest.total_size_bytes, + created_by_user_id=manifest.created_by_user_id, + created_by_username=manifest.created_by_username, + validation_status="valid", + validation_message="Backup ist lesbar", + ) + except Exception: + return default_summary + + @staticmethod + def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str: + digest = hashlib.sha256() + digest.update(DATABASE_DUMP_NAME.encode("utf-8")) + BackupService._update_digest_from_file(digest, dump_path) + + for file_path in sorted(storage_dir.rglob("*")): + if file_path.is_dir(): + continue + digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8")) + BackupService._update_digest_from_file(digest, file_path) + + return digest.hexdigest() + + @staticmethod + def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool: + digest = hashlib.sha256() + if DATABASE_DUMP_NAME not in archive.namelist(): + return False + + digest.update(DATABASE_DUMP_NAME.encode("utf-8")) + with archive.open(DATABASE_DUMP_NAME) as dump_file: + BackupService._update_digest_from_stream(digest, dump_file) + + for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")): + digest.update(name.encode("utf-8")) + with archive.open(name) as storage_file: + BackupService._update_digest_from_stream(digest, storage_file) + + return digest.hexdigest() == expected_checksum + + @staticmethod + def _anonymized_database_host() -> str: + parsed = make_url(settings.database_url) + host = parsed.host or "unknown" + digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12] + return f"sha256:{digest}" + + @staticmethod + def _database_name() -> str: + parsed = make_url(settings.database_url) + return parsed.database or "unknown" + + @staticmethod + def _pg_dump_database_url() -> str: + parsed = make_url(settings.database_url) + normalized = parsed.set(drivername="postgresql") + return normalized.render_as_string(hide_password=False) + + @staticmethod + def _update_digest_from_file(digest, file_path: Path) -> None: + with file_path.open("rb") as file_handle: + BackupService._update_digest_from_stream(digest, file_handle) + + @staticmethod + def _update_digest_from_stream(digest, stream) -> None: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 660b1e5..4fb2749 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -7,7 +7,7 @@ services: environment: DATABASE_URL: ${DATABASE_URL} APP_NAME: Hermes API - APP_VERSION: 0.1.0 + APP_VERSION: 0.9.1 SECRET_KEY: ${SECRET_KEY} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} diff --git a/backend/hermes/dockerfile b/backend/hermes/dockerfile index 71f483b..11c1906 100644 --- a/backend/hermes/dockerfile +++ b/backend/hermes/dockerfile @@ -2,6 +2,10 @@ FROM python:3.13-slim WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + COPY . . RUN pip install uv diff --git a/docker-compose.yml b/docker-compose.yml index 94bf31f..3c8ddf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: environment: DATABASE_URL: ${DATABASE_URL} APP_NAME: Hermes API - APP_VERSION: 0.1.0 + APP_VERSION: 0.9.1 SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} diff --git a/frontend/athena/app/api/backups/[filename]/delete/route.ts b/frontend/athena/app/api/backups/[filename]/delete/route.ts new file mode 100644 index 0000000..0273b73 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/delete/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + filename: string; + }>; +}; + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + const { filename } = await context.params; + return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`); +} diff --git a/frontend/athena/app/api/backups/[filename]/download/route.ts b/frontend/athena/app/api/backups/[filename]/download/route.ts new file mode 100644 index 0000000..e5827f9 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/download/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + filename: string; + }>; +}; + +export async function GET(request: NextRequest, context: Params) { + const { filename } = await context.params; + return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`); +} diff --git a/frontend/athena/app/api/backups/[filename]/restore/route.ts b/frontend/athena/app/api/backups/[filename]/restore/route.ts new file mode 100644 index 0000000..3b204b4 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/restore/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + filename: string; + }>; +}; + +export async function POST(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + const { filename } = await context.params; + return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`); +} diff --git a/frontend/athena/app/api/backups/[filename]/validate/route.ts b/frontend/athena/app/api/backups/[filename]/validate/route.ts new file mode 100644 index 0000000..36037b3 --- /dev/null +++ b/frontend/athena/app/api/backups/[filename]/validate/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + filename: string; + }>; +}; + +export async function POST(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + const { filename } = await context.params; + return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`); +} diff --git a/frontend/athena/app/api/backups/create/route.ts b/frontend/athena/app/api/backups/create/route.ts new file mode 100644 index 0000000..3928270 --- /dev/null +++ b/frontend/athena/app/api/backups/create/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + return proxyHermesRequest(request, "/backups/create"); +} diff --git a/frontend/athena/app/api/backups/route.ts b/frontend/athena/app/api/backups/route.ts new file mode 100644 index 0000000..ccf2978 --- /dev/null +++ b/frontend/athena/app/api/backups/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/backups"); +} diff --git a/frontend/athena/app/backups/page.tsx b/frontend/athena/app/backups/page.tsx new file mode 100644 index 0000000..3c70d97 --- /dev/null +++ b/frontend/athena/app/backups/page.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import SummaryCard from "@/components/common/SummaryCard"; +import { useToast } from "@/components/common/ToastProvider"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { ApiSuccess } from "@/types/audit"; +import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup"; +import type { CurrentUser } from "@/types/rbac"; + +const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"; + +function getErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? fallback; + } + return fallback; +} + +function formatBytes(bytes: number) { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +} + +function formatDate(value: string | null) { + if (!value) { + return "Unbekannt"; + } + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export default function BackupsPage() { + const { showToast } = useToast(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [validationByFile, setValidationByFile] = useState>({}); + const [validatingFile, setValidatingFile] = useState(""); + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreConfirm, setRestoreConfirm] = useState(""); + const [restoring, setRestoring] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + + const loadBackups = useCallback(async () => { + setError(""); + try { + const response = await api.get>("/backups"); + setData(response.data.data); + } catch (err) { + setError(getErrorMessage(err, "Backups konnten nicht geladen werden.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadBackups(); + }); + }, [loadBackups]); + + useEffect(() => { + queueMicrotask(async () => { + try { + const response = await api.get("/me"); + setCurrentUser(response.data); + } catch { + setCurrentUser(null); + } + }); + }, []); + + const cards = useMemo(() => { + if (!data) { + return []; + } + return [ + { label: "Backups", value: data.total_count }, + { label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) }, + { label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" }, + ]; + }, [data]); + + const canCreate = hasPermission(currentUser, "backup.create"); + const canDownload = hasPermission(currentUser, "backup.download"); + const canDelete = hasPermission(currentUser, "backup.delete"); + const canRestore = hasPermission(currentUser, "backup.restore"); + + async function createBackup() { + setCreating(true); + try { + await api.post>("/backups/create"); + await loadBackups(); + showToast({ type: "success", title: "Backup erstellt" }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht erstellt werden", + description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."), + }); + } finally { + setCreating(false); + } + } + + async function validateBackup(filename: string) { + setValidatingFile(filename); + try { + const response = await api.post>(`/backups/${encodeURIComponent(filename)}/validate`); + setValidationByFile((current) => ({ ...current, [filename]: response.data.data })); + showToast({ + type: response.data.data.valid ? "success" : "error", + title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen", + description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "), + }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht validiert werden", + description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."), + }); + } finally { + setValidatingFile(""); + } + } + + async function deleteBackup() { + if (!deleteTarget) { + return; + } + setDeleting(true); + try { + await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`); + setDeleteTarget(null); + await loadBackups(); + showToast({ type: "success", title: "Backup geloescht" }); + } catch (err) { + showToast({ + type: "error", + title: "Backup konnte nicht geloescht werden", + description: getErrorMessage(err, "Bitte versuche es erneut."), + }); + } finally { + setDeleting(false); + } + } + + async function restoreBackup() { + if (!restoreTarget) { + return; + } + setRestoring(true); + try { + await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, { + confirm_text: restoreConfirm, + }); + showToast({ type: "success", title: "Restore abgeschlossen" }); + } catch (err) { + showToast({ + type: "info", + title: "CLI-Restore erforderlich", + description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."), + }); + } finally { + setRestoring(false); + } + } + + if (loading) { + return
Backups werden geladen...
; + } + + if (error || !data) { + return
{error || "Keine Backups verfuegbar"}
; + } + + return ( +
+
+
+

Backups

+

PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {cards.map((card) => ( + + ))} +
+ +
+
+

Verfuegbare Backup-Dateien

+
+ {data.items.length === 0 ? ( +
Noch keine Backup-Dateien vorhanden.
+ ) : ( +
+ {data.items.map((backup) => { + const validation = validationByFile[backup.filename]; + return ( +
+
+
+
+

{backup.filename}

+ + {backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"} + +
+
+
+

Erstellt

+

{formatDate(backup.created_at)}

+
+
+

Groesse

+

{formatBytes(backup.size_bytes)}

+
+
+

Version

+

{backup.app_version || "Unbekannt"}

+
+
+

Ersteller

+

{backup.created_by_username || "System"}

+
+
+
+
+

Datenbank

+

{backup.database_name || "Unbekannt"}

+
+
+

Dateien

+

{backup.file_count}

+
+
+

Storage-Pfad

+

{backup.storage_base_path || "-"}

+
+
+ {validation && ( +
+
+ {validation.valid ? : } +
+

{validation.message}

+

Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}

+ {!validation.valid && validation.issues.length > 0 && ( +

{validation.issues.join(" | ")}

+ )} + {validation.requires_cli_restore && ( +

Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.

+ )} +
+
+
+ )} +
+
+ {canDownload && ( + + + Download + + )} + {canRestore && ( + <> + + + + )} + {canDelete && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + { + if (!open) { + setDeleteTarget(null); + } + }} + onConfirm={() => void deleteBackup()} + > + {deleteTarget && ( +
+

{deleteTarget.filename}

+

{formatBytes(deleteTarget.size_bytes)}

+
+ )} +
+ + { + if (!open) { + setRestoreTarget(null); + setRestoreConfirm(""); + } + }} + onConfirm={() => void restoreBackup()} + > +
+
+
+ +

Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.

+
+
+
+

Bestaetigungstext

+ setRestoreConfirm(event.target.value)} + placeholder={RESTORE_CONFIRM_TEXT} + /> +
+ {restoreTarget && ( +

+ Ziel-Backup: {restoreTarget.filename} +

+ )} +
+
+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 00ed94e..02d54e2 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { BookOpen, ClipboardList, FileText, + HardDriveDownload, LayoutDashboard, Package, Settings, @@ -78,6 +79,12 @@ const menu = [ name: "Dokumente", href: "/documents", }, + { + icon: HardDriveDownload, + name: "Backups", + href: "/backups", + permission: "backup.read", + }, { icon: Settings, name: "Einstellungen", @@ -135,7 +142,7 @@ export default function Sidebar() {
- Olympus CRM v0.1 + Olympus CRM v0.9.1
); diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx index a3e1b37..f2fa6da 100644 --- a/frontend/athena/components/common/ConfirmDialog.tsx +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -18,6 +18,8 @@ type Props = { description: string; confirmLabel?: string; pending?: boolean; + pendingLabel?: string; + confirmDisabled?: boolean; children?: ReactNode; onOpenChange: (open: boolean) => void; onConfirm: () => void; @@ -29,6 +31,8 @@ export default function ConfirmDialog({ description, confirmLabel = "Löschen", pending = false, + pendingLabel = "Wird gelöscht...", + confirmDisabled = false, children, onOpenChange, onConfirm, @@ -56,9 +60,9 @@ export default function ConfirmDialog({ type="button" variant="destructive" onClick={onConfirm} - disabled={pending} + disabled={pending || confirmDisabled} > - {pending ? "Wird gelöscht..." : confirmLabel} + {pending ? pendingLabel : confirmLabel} diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index 80d0ef8..c317949 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -23,6 +23,7 @@ export const config = { "/roles/:path*", "/customers/:path*", "/repairs/:path*", + "/backups/:path*", "/settings/:path*", ], }; diff --git a/frontend/athena/types/backup.ts b/frontend/athena/types/backup.ts new file mode 100644 index 0000000..7341c2b --- /dev/null +++ b/frontend/athena/types/backup.ts @@ -0,0 +1,49 @@ +export interface BackupManifest { + backup_id: string; + created_at: string; + app_version: string; + backup_type: string; + database_url_host_anonymized: string; + database_name: string; + storage_base_path: string; + included_sections: string[]; + file_count: number; + total_size_bytes: number; + checksum_sha256: string; + created_by_user_id: number | null; + created_by_username: string; +} + +export interface BackupSummary { + filename: string; + size_bytes: number; + created_at: string | null; + app_version: string; + backup_type: string; + database_name: string; + storage_base_path: string; + file_count: number; + total_size_bytes: number; + created_by_user_id: number | null; + created_by_username: string; + validation_status: string; + validation_message: string; +} + +export interface BackupListResponse { + items: BackupSummary[]; + total_count: number; + total_size_bytes: number; + latest_backup_at: string | null; +} + +export interface BackupValidationResponse { + filename: string; + valid: boolean; + message: string; + issues: string[]; + checksum_valid: boolean; + restore_supported: boolean; + requires_cli_restore: boolean; + manifest: BackupManifest | null; +} diff --git a/scripts/backup.sh b/scripts/backup.sh old mode 100755 new mode 100644 index d72eea6..25aef2d --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,33 +1,139 @@ #!/usr/bin/env bash set -euo pipefail -cd "$(dirname "$0")/.." +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" -BACKUP_DIR="${BACKUP_DIR:-./backups}" 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)" -TARGET_DIR="${BACKUP_DIR}/${TIMESTAMP}" +FILENAME="olympus-backup-${TIMESTAMP}.zip" +WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT -mkdir -p "${TARGET_DIR}" +export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME -echo "==> Creating backup in ${TARGET_DIR}" +python3 - <<'PY' +from pathlib import Path +import os +import shutil -if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then - echo "==> Creating PostgreSQL dump from container ${POSTGRES_CONTAINER}" - docker exec "${POSTGRES_CONTAINER}" pg_dump -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" > "${TARGET_DIR}/postgres.sql" -elif command -v pg_dump >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then - echo "==> Creating PostgreSQL dump from DATABASE_URL" - pg_dump "${DATABASE_URL}" > "${TARGET_DIR}/postgres.sql" -else - echo "WARN: PostgreSQL dump skipped. Set POSTGRES_CONTAINER or install pg_dump with DATABASE_URL." -fi +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) -if [[ -d "${STORAGE_HOST_PATH}" ]]; then - echo "==> Archiving storage directory" - tar -czf "${TARGET_DIR}/storage.tar.gz" -C "${STORAGE_HOST_PATH}" . -else - echo "WARN: Storage directory ${STORAGE_HOST_PATH} not found; storage backup skipped." -fi +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 -echo "INFO: .env is not copied automatically. Store production secrets separately and securely." -echo "==> Backup completed" +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}" diff --git a/scripts/restore.sh b/scripts/restore.sh old mode 100755 new mode 100644 index d15d5fe..f44830b --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -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 " >&2 +if [[ -z "$BACKUP_SOURCE" || ! -f "$BACKUP_SOURCE" ]]; then + echo "Usage: scripts/restore.sh " >&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}"