diff --git a/.env.example b/.env.example index 155c54e..77728fe 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,6 @@ 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 5bd760f..b36a9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,6 @@ out/ # Runtime storage # =========================== storage/ -/backups/ -*.dump # =========================== # Coverage diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3bced00..62a0499 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1060,29 +1060,9 @@ 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` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`. +`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. -`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`. +`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`. ### Knowledge-RBAC diff --git a/README-DEV.md b/README-DEV.md index d309d74..3aee4d1 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -134,46 +134,6 @@ 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 7c0efb0..040120d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,24 +201,13 @@ 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 - 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 +## v0.9.1 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden - Keine Vermischung mit internen Olympus-Benutzern -## v0.9.3 - Tickets, geplant +## v0.9.2 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index 5704b10..8349be7 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -66,8 +66,6 @@ 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 deleted file mode 100644 index 75e131e..0000000 --- a/backend/hermes/app/api/backups.py +++ /dev/null @@ -1,150 +0,0 @@ -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 02d2752..7488a6d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -4,7 +4,6 @@ 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 @@ -17,7 +16,6 @@ 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__) @@ -164,27 +162,6 @@ 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 2a3d97f..23c8c71 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.9.1" + app_version: str = "0.1.0" access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" log_level: str = "INFO" diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index c860b24..260eb13 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -25,7 +25,6 @@ import app.models.customer import app.models.knowledge import app.models.audit import app.models.user -import app.models.inventory import app.models.repair import app.models.repair_estimate import app.models.system_setting diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 04c84ca..ffb31e2 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -11,7 +11,6 @@ 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 @@ -34,13 +33,12 @@ configure_logging() app = FastAPI( title="Hermes API", - version="0.9.1", + version="0.1.0", 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/models/inventory.py b/backend/hermes/app/models/inventory.py index ecfdfc9..9428b9e 100644 --- a/backend/hermes/app/models/inventory.py +++ b/backend/hermes/app/models/inventory.py @@ -103,11 +103,7 @@ class InventoryStockMovement(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) item: Mapped[InventoryItem] = relationship(back_populates="movements") - actor = relationship( - "User", - foreign_keys="InventoryStockMovement.actor_user_id", - lazy="joined", - ) + actor = relationship("User", lazy="joined") @property def actor_username(self) -> str: diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index 6b32497..53ba9e2 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -56,11 +56,7 @@ class RepairStatusHistory(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) repair: Mapped[Repair] = relationship(back_populates="history") - actor = relationship( - "User", - foreign_keys="RepairStatusHistory.actor_user_id", - lazy="joined", - ) + actor = relationship("User", lazy="joined") @property def actor_username(self) -> str: @@ -109,11 +105,7 @@ class RepairDocument(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) repair: Mapped[Repair] = relationship(back_populates="documents") - uploaded_by = relationship( - "User", - foreign_keys="RepairDocument.uploaded_by_user_id", - lazy="joined", - ) + uploaded_by = relationship("User", lazy="joined") @property def uploaded_by_username(self) -> str: diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 9cdda96..b9d6a80 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -40,16 +40,7 @@ class RepairEstimate(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) repair = relationship("Repair", lazy="joined") - created_by = relationship( - "User", - foreign_keys="RepairEstimate.created_by_user_id", - lazy="joined", - ) - accounting_transferred_by = relationship( - "User", - foreign_keys="RepairEstimate.accounting_transferred_by_user_id", - lazy="joined", - ) + created_by = relationship("User", lazy="joined") items: Mapped[list["RepairEstimateItem"]] = relationship( back_populates="estimate", cascade="all, delete-orphan", @@ -101,8 +92,4 @@ class RepairEstimateEvent(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) estimate: Mapped[RepairEstimate] = relationship(back_populates="events") - actor = relationship( - "User", - foreign_keys="RepairEstimateEvent.actor_user_id", - lazy="joined", - ) + actor = relationship("User", lazy="joined") diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 6661bff..9974a8b 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -102,11 +102,6 @@ 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 = { @@ -139,9 +134,6 @@ 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 deleted file mode 100644 index 661d1e9..0000000 --- a/backend/hermes/app/schemas/backup.py +++ /dev/null @@ -1,57 +0,0 @@ -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 14110e0..12518a6 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -215,13 +215,6 @@ 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 deleted file mode 100644 index b84ffe2..0000000 --- a/backend/hermes/app/services/backup_service.py +++ /dev/null @@ -1,373 +0,0 @@ -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 4fb2749..660b1e5 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.9.1 + APP_VERSION: 0.1.0 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 11c1906..71f483b 100644 --- a/backend/hermes/dockerfile +++ b/backend/hermes/dockerfile @@ -2,10 +2,6 @@ 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 3c8ddf9..94bf31f 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.9.1 + APP_VERSION: 0.1.0 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 deleted file mode 100644 index 0273b73..0000000 --- a/frontend/athena/app/api/backups/[filename]/delete/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index e5827f9..0000000 --- a/frontend/athena/app/api/backups/[filename]/download/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 3b204b4..0000000 --- a/frontend/athena/app/api/backups/[filename]/restore/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 36037b3..0000000 --- a/frontend/athena/app/api/backups/[filename]/validate/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3928270..0000000 --- a/frontend/athena/app/api/backups/create/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index ccf2978..0000000 --- a/frontend/athena/app/api/backups/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 3c70d97..0000000 --- a/frontend/athena/app/backups/page.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"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 02d54e2..00ed94e 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -6,7 +6,6 @@ import { BookOpen, ClipboardList, FileText, - HardDriveDownload, LayoutDashboard, Package, Settings, @@ -79,12 +78,6 @@ const menu = [ name: "Dokumente", href: "/documents", }, - { - icon: HardDriveDownload, - name: "Backups", - href: "/backups", - permission: "backup.read", - }, { icon: Settings, name: "Einstellungen", @@ -142,7 +135,7 @@ export default function Sidebar() {
- Olympus CRM v0.9.1 + Olympus CRM v0.1
); diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx index f2fa6da..a3e1b37 100644 --- a/frontend/athena/components/common/ConfirmDialog.tsx +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -18,8 +18,6 @@ type Props = { description: string; confirmLabel?: string; pending?: boolean; - pendingLabel?: string; - confirmDisabled?: boolean; children?: ReactNode; onOpenChange: (open: boolean) => void; onConfirm: () => void; @@ -31,8 +29,6 @@ export default function ConfirmDialog({ description, confirmLabel = "Löschen", pending = false, - pendingLabel = "Wird gelöscht...", - confirmDisabled = false, children, onOpenChange, onConfirm, @@ -60,9 +56,9 @@ export default function ConfirmDialog({ type="button" variant="destructive" onClick={onConfirm} - disabled={pending || confirmDisabled} + disabled={pending} > - {pending ? pendingLabel : confirmLabel} + {pending ? "Wird gelöscht..." : confirmLabel} diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index c317949..80d0ef8 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -23,7 +23,6 @@ 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 deleted file mode 100644 index 7341c2b..0000000 --- a/frontend/athena/types/backup.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 100644 new mode 100755 index 25aef2d..d72eea6 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,139 +1,33 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT_DIR" +cd "$(dirname "$0")/.." +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)" -FILENAME="olympus-backup-${TIMESTAMP}.zip" -WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")" -trap 'rm -rf "$WORK_DIR"' EXIT +TARGET_DIR="${BACKUP_DIR}/${TIMESTAMP}" -export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME +mkdir -p "${TARGET_DIR}" -python3 - <<'PY' -from pathlib import Path -import os -import shutil +echo "==> Creating backup in ${TARGET_DIR}" -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 [[ -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 -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 +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 -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}" +echo "INFO: .env is not copied automatically. Store production secrets separately and securely." +echo "==> Backup completed" diff --git a/scripts/restore.sh b/scripts/restore.sh old mode 100644 new mode 100755 index f44830b..d15d5fe --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -1,151 +1,46 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT_DIR" +cd "$(dirname "$0")/.." 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" || ! -f "$BACKUP_SOURCE" ]]; then - echo "Usage: scripts/restore.sh " >&2 +if [[ -z "${BACKUP_SOURCE}" || ! -d "${BACKUP_SOURCE}" ]]; then + echo "Usage: scripts/restore.sh " >&2 exit 1 fi -if [[ -z "$DATABASE_URL" ]]; then - echo "DATABASE_URL ist erforderlich." >&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 ! 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." +if [[ "${confirmation}" != "RESTORE" ]]; then + echo "Restore cancelled" exit 0 fi -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 +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." fi +else + echo "WARN: postgres.sql not found; database 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 +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 -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}" +echo "==> Restore completed"