Phase 6: remote backup destinations — SFTP & S3 (0.6.0)

- BackupDestination model + backup_destination_service (SFTP via paramiko,
  S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
  test, list/delete remote backups. backups.py: POST /{id}/backup/push and
  POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
  can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.

Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 21:50:03 +00:00
co-authored by Claude Opus 4.8
parent 59037f4287
commit 7bd449101d
12 changed files with 999 additions and 42 deletions
+118 -4
View File
@@ -1,18 +1,26 @@
"""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_service, compose_service
from services import (
audit_service,
backup_destination_service as dest_service,
backup_service,
compose_service,
)
router = APIRouter(prefix="/api/stacks", tags=["backups"])
@@ -21,6 +29,12 @@ def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _backup_filename(stack_id: str, include_volumes: bool) -> str:
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
suffix = "full" if include_volumes else "config"
return f"backup-{stack_id}-{suffix}-{date}.tar.gz"
@router.get("/{stack_id}/backup")
async def backup_stack(
stack_id: str,
@@ -43,12 +57,10 @@ async def backup_stack(
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",
filename=_backup_filename(stack_id, include_volumes),
)
@@ -94,3 +106,105 @@ async def restore_stack(
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)