"""Stack backup (incl. volumes) and restore.""" from __future__ import annotations import os import tempfile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session from auth import require_admin from database import get_session from models.stack import Stack from models.user import User from services import audit_service, backup_service, compose_service router = APIRouter(prefix="/api/stacks", tags=["backups"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" @router.get("/{stack_id}/backup") async def backup_stack( stack_id: str, request: Request, include_volumes: bool = Query(True), stop_first: bool = Query(True), session: Session = Depends(get_session), user: User = Depends(require_admin), ): stack = session.get(Stack, stack_id) if not stack: raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found") try: path = await backup_service.create_backup( stack_id, stack.name, include_volumes=include_volumes, stop_first=stop_first, ) except backup_service.BackupError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc audit_service.record( session, user=user.username, action="stack.backup", target=stack_id, detail=f"volumes={include_volumes}", ip=_ip(request), ) date = compose_service.now().strftime("%Y%m%d-%H%M%S") suffix = "full" if include_volumes else "config" return FileResponse( path, media_type="application/gzip", filename=f"backup-{stack_id}-{suffix}-{date}.tar.gz", ) @router.post("/restore") async def restore_stack( request: Request, file: UploadFile = File(...), target_id: str | None = Form(None), overwrite: bool = Form(False), restore_volumes: bool = Form(True), session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") try: while chunk := await file.read(1024 * 1024): tmp.write(chunk) tmp.close() target = compose_service.slugify(target_id) if target_id else None try: result = backup_service.restore_backup( tmp.name, target_id=target, overwrite=overwrite, restore_volumes=restore_volumes, ) except backup_service.BackupError as exc: # 409 for the "already exists" conflict, 400 for malformed backups. code = 409 if "already exists" in str(exc) else 400 raise HTTPException(status_code=code, detail=str(exc)) from exc stack_id = result["stack_id"] stack = session.get(Stack, stack_id) if not stack: session.add(Stack(id=stack_id, name=result.get("name", stack_id))) session.commit() audit_service.record( session, user=user.username, action="stack.restore", target=stack_id, detail=f"volumes={result['volumes_restored']}", ip=_ip(request), ) return result finally: if os.path.exists(tmp.name): os.unlink(tmp.name)