- backup_filename() crashed with NameError (bare now()) since 0.8.0 — broke every scheduled backup at the upload step, agent backup download and the central remote-backup/push endpoints. The local manual path worked only because the router had its own copy (now an alias). - restore: the manifest stack_id from an uploaded backup is now slugified too — a crafted '../../...' id could previously escape STACKS_DIR. - create_backup no longer starts a previously-stopped stack (stop/restart only when the stack was actually running). - overwrite-restore wipes the existing volume contents before extracting, so files created since the backup no longer survive underneath it. Verified end-to-end: full/config backup contents (compose, .env, .secrets, bind dirs, extras, volume tars), delete→restore round-trip incl. volume data, rename restore with volume re-prefixing, 409 conflict + overwrite, traversal guard, scheduled run + retention prune + restore-from against real MinIO, and the complete remote-agent cycle (download/push/restore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
6.9 KiB
Python
208 lines
6.9 KiB
Python
"""Stack backup (incl. volumes) and restore."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from sqlmodel import Session
|
|
|
|
from auth import require_admin
|
|
from database import get_session
|
|
from models.backup_destination import BackupDestination
|
|
from models.stack import Stack
|
|
from models.user import User
|
|
from services import (
|
|
audit_service,
|
|
backup_destination_service as dest_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"
|
|
|
|
|
|
_backup_filename = backup_service.backup_filename
|
|
|
|
|
|
@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),
|
|
)
|
|
return FileResponse(
|
|
path,
|
|
media_type="application/gzip",
|
|
filename=_backup_filename(stack_id, include_volumes),
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Push to / restore from a remote destination
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class PushBody(BaseModel):
|
|
destination_id: int
|
|
include_volumes: bool = True
|
|
stop_first: bool = True
|
|
|
|
|
|
class RestoreFromBody(BaseModel):
|
|
destination_id: int
|
|
name: str
|
|
target_id: str | None = None
|
|
overwrite: bool = False
|
|
restore_volumes: bool = True
|
|
|
|
|
|
def _get_dest(session: Session, dest_id: int) -> BackupDestination:
|
|
d = session.get(BackupDestination, dest_id)
|
|
if not d:
|
|
raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found")
|
|
return d
|
|
|
|
|
|
@router.post("/{stack_id}/backup/push")
|
|
async def push_backup(
|
|
stack_id: str,
|
|
body: PushBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
stack = session.get(Stack, stack_id)
|
|
if not stack:
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
dest = _get_dest(session, body.destination_id)
|
|
try:
|
|
path = await backup_service.create_backup(
|
|
stack_id, stack.name,
|
|
include_volumes=body.include_volumes, stop_first=body.stop_first,
|
|
)
|
|
except backup_service.BackupError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
filename = _backup_filename(stack_id, body.include_volumes)
|
|
try:
|
|
remote = await asyncio.to_thread(dest_service.upload, dest, path, filename)
|
|
except dest_service.DestinationError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
finally:
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
|
|
audit_service.record(
|
|
session, user=user.username, action="stack.backup.push",
|
|
target=stack_id, detail=f"{dest.name}:{filename}", ip=_ip(request),
|
|
)
|
|
return {"ok": True, "destination": dest.name, "name": filename, "remote": remote}
|
|
|
|
|
|
@router.post("/restore-from")
|
|
async def restore_from_destination(
|
|
body: RestoreFromBody,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
dest = _get_dest(session, body.destination_id)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
|
tmp.close()
|
|
try:
|
|
try:
|
|
await asyncio.to_thread(dest_service.download, dest, body.name, tmp.name)
|
|
except dest_service.DestinationError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
|
|
target = compose_service.slugify(body.target_id) if body.target_id else None
|
|
try:
|
|
result = backup_service.restore_backup(
|
|
tmp.name, target_id=target,
|
|
overwrite=body.overwrite, restore_volumes=body.restore_volumes,
|
|
)
|
|
except backup_service.BackupError as exc:
|
|
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"]
|
|
if not session.get(Stack, stack_id):
|
|
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"from {dest.name}:{body.name}", ip=_ip(request),
|
|
)
|
|
return result
|
|
finally:
|
|
if os.path.exists(tmp.name):
|
|
os.unlink(tmp.name)
|