feat(backup): add enterprise backup & restore foundation
This commit is contained in:
parent
7835d3ca75
commit
6c917df515
30 changed files with 1538 additions and 61 deletions
|
|
@ -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."):
|
||||
|
|
|
|||
150
backend/hermes/app/api/backups.py
Normal file
150
backend/hermes/app/api/backups.py
Normal 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")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
57
backend/hermes/app/schemas/backup.py
Normal file
57
backend/hermes/app/schemas/backup.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
373
backend/hermes/app/services/backup_service.py
Normal file
373
backend/hermes/app/services/backup_service.py
Normal 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)
|
||||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue