- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper container), upload restore with rename/overwrite/conflict detection. - Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event subscriptions; wired into the update checker and stack lifecycle. - Settings page: update-check interval, webhook CRUD + test, user management (with last-admin safeguards). - Audit log page (searchable, paginated). - Mobile-responsive sidebar/layout. Multi-host agents and remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
"""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)
|