Compare commits

...

1 commit

Author SHA1 Message Date
Schubert Ferenc
6c917df515 feat(backup): add enterprise backup & restore foundation 2026-07-05 17:13:31 +02:00
30 changed files with 1538 additions and 61 deletions

View file

@ -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

2
.gitignore vendored
View file

@ -43,6 +43,8 @@ out/
# Runtime storage
# ===========================
storage/
/backups/
*.dump
# ===========================
# Coverage

View file

@ -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

View file

@ -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 <backup-zip>` 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:

View file

@ -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

View file

@ -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."):

View file

@ -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")

View file

@ -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(

View file

@ -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"

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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:-}

View file

@ -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

View file

@ -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}

View file

@ -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)}`);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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`);
}

View file

@ -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");
}

View file

@ -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");
}

View file

@ -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<BackupListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<BackupSummary | null>(null);
const [deleting, setDeleting] = useState(false);
const [validationByFile, setValidationByFile] = useState<Record<string, BackupValidationResponse>>({});
const [validatingFile, setValidatingFile] = useState("");
const [restoreTarget, setRestoreTarget] = useState<BackupSummary | null>(null);
const [restoreConfirm, setRestoreConfirm] = useState("");
const [restoring, setRestoring] = useState(false);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const loadBackups = useCallback(async () => {
setError("");
try {
const response = await api.get<ApiSuccess<BackupListResponse>>("/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<CurrentUser>("/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<ApiSuccess<BackupSummary>>("/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<ApiSuccess<BackupValidationResponse>>(`/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Backups werden geladen...</div>;
}
if (error || !data) {
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Backups verfuegbar"}</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Backups</h1>
<p className="mt-1 text-sm text-slate-500">PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => void loadBackups()}>
<RefreshCcw />
Aktualisieren
</Button>
{canCreate && (
<Button type="button" onClick={() => void createBackup()} disabled={creating}>
<Archive />
{creating ? "Backup wird erstellt..." : "Backup erstellen"}
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<SummaryCard key={card.label} label={card.label} value={card.value} />
))}
</div>
<section className="rounded-lg border bg-white">
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold text-slate-950">Verfuegbare Backup-Dateien</h2>
</div>
{data.items.length === 0 ? (
<div className="px-6 py-10 text-sm text-slate-500">Noch keine Backup-Dateien vorhanden.</div>
) : (
<div className="divide-y">
{data.items.map((backup) => {
const validation = validationByFile[backup.filename];
return (
<div key={backup.filename} className="px-6 py-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<p className="truncate font-semibold text-slate-950">{backup.filename}</p>
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
backup.validation_status === "valid"
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20"
}`}>
{backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"}
</span>
</div>
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Erstellt</p>
<p>{formatDate(backup.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Groesse</p>
<p>{formatBytes(backup.size_bytes)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Version</p>
<p>{backup.app_version || "Unbekannt"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Ersteller</p>
<p>{backup.created_by_username || "System"}</p>
</div>
</div>
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Datenbank</p>
<p>{backup.database_name || "Unbekannt"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Dateien</p>
<p>{backup.file_count}</p>
</div>
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Storage-Pfad</p>
<p className="break-all">{backup.storage_base_path || "-"}</p>
</div>
</div>
{validation && (
<div className={`mt-4 rounded-lg border p-4 text-sm ${
validation.valid
? "border-emerald-200 bg-emerald-50 text-emerald-950"
: "border-amber-200 bg-amber-50 text-amber-950"
}`}>
<div className="flex items-start gap-3">
{validation.valid ? <RotateCcw className="mt-0.5 h-4 w-4" /> : <AlertTriangle className="mt-0.5 h-4 w-4" />}
<div className="space-y-1">
<p className="font-medium">{validation.message}</p>
<p>Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}</p>
{!validation.valid && validation.issues.length > 0 && (
<p>{validation.issues.join(" | ")}</p>
)}
{validation.requires_cli_restore && (
<p>Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.</p>
)}
</div>
</div>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{canDownload && (
<a
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
className={buttonVariants({ variant: "outline" })}
>
<Download />
Download
</a>
)}
{canRestore && (
<>
<Button
type="button"
variant="outline"
onClick={() => void validateBackup(backup.filename)}
disabled={validatingFile === backup.filename}
>
<ShieldAlert />
{validatingFile === backup.filename ? "Prueft..." : "Validieren"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setRestoreTarget(backup);
setRestoreConfirm("");
}}
>
<RotateCcw />
Restore vorbereiten
</Button>
</>
)}
{canDelete && (
<Button type="button" variant="destructive" onClick={() => setDeleteTarget(backup)}>
<Trash2 />
Loeschen
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</section>
<ConfirmDialog
open={deleteTarget !== null}
title="Backup loeschen"
description="Die Backup-Datei wird dauerhaft entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden."
confirmLabel="Backup loeschen"
pending={deleting}
pendingLabel="Backup wird geloescht..."
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(null);
}
}}
onConfirm={() => void deleteBackup()}
>
{deleteTarget && (
<div className="space-y-1 text-sm text-slate-700">
<p className="font-medium">{deleteTarget.filename}</p>
<p>{formatBytes(deleteTarget.size_bytes)}</p>
</div>
)}
</ConfirmDialog>
<ConfirmDialog
open={restoreTarget !== null}
title="Restore vorbereiten"
description="Restore ist ein Hochrisiko-Vorgang. Bitte bestaetige den Text exakt, bevor Olympus den CLI-Restore vorbereitet."
confirmLabel="Restore starten"
pending={restoring}
pendingLabel="Restore wird vorbereitet..."
confirmDisabled={restoreConfirm !== RESTORE_CONFIRM_TEXT}
onOpenChange={(open) => {
if (!open) {
setRestoreTarget(null);
setRestoreConfirm("");
}
}}
onConfirm={() => void restoreBackup()}
>
<div className="space-y-3">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
<div className="flex gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-700">Bestaetigungstext</p>
<Input
value={restoreConfirm}
onChange={(event) => setRestoreConfirm(event.target.value)}
placeholder={RESTORE_CONFIRM_TEXT}
/>
</div>
{restoreTarget && (
<p className="text-sm text-slate-600">
Ziel-Backup: <span className="font-medium text-slate-900">{restoreTarget.filename}</span>
</p>
)}
</div>
</ConfirmDialog>
</div>
);
}

View file

@ -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() {
</nav>
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
Olympus CRM v0.1
Olympus CRM v0.9.1
</div>
</aside>
);

View file

@ -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}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -23,6 +23,7 @@ export const config = {
"/roles/:path*",
"/customers/:path*",
"/repairs/:path*",
"/backups/:path*",
"/settings/:path*",
],
};

View file

@ -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;
}

150
scripts/backup.sh Executable file → Normal file
View file

@ -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}"

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

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