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
+171
View File
@@ -0,0 +1,171 @@
"""Backup destination management (SFTP / S3-compatible)."""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from auth import get_current_user, require_admin
from database import get_session
from models.backup_destination import (
DESTINATION_TYPES,
SECRET_KEYS,
BackupDestination,
DestinationCreate,
DestinationRead,
DestinationUpdate,
)
from models.user import User
from services import audit_service, backup_destination_service as dest_service
router = APIRouter(prefix="/api/backups/destinations", tags=["backups"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _mask(config: dict) -> dict:
return {k: ("••••••" if k in SECRET_KEYS and v else v) for k, v in config.items()}
def _to_read(d: BackupDestination) -> DestinationRead:
return DestinationRead(
id=d.id,
name=d.name,
type=d.type,
config=_mask(dest_service.parse_config(d)),
created_at=d.created_at,
)
def _get_or_404(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.get("", response_model=list[DestinationRead])
def list_destinations(
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list[DestinationRead]:
rows = session.exec(select(BackupDestination).order_by(BackupDestination.id)).all()
return [_to_read(d) for d in rows]
@router.post("", response_model=DestinationRead, status_code=201)
def create_destination(
body: DestinationCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> DestinationRead:
if body.type not in DESTINATION_TYPES:
raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'")
d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config))
session.add(d)
session.commit()
session.refresh(d)
audit_service.record(
session, user=user.username, action="destination.create", target=body.name,
detail=body.type, ip=_ip(request),
)
return _to_read(d)
@router.put("/{dest_id}", response_model=DestinationRead)
def update_destination(
dest_id: int,
body: DestinationUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> DestinationRead:
d = _get_or_404(session, dest_id)
if body.name is not None:
d.name = body.name
if body.config is not None:
# Merge so masked/blank secrets don't wipe stored ones.
existing = dest_service.parse_config(d)
for k, v in body.config.items():
if k in SECRET_KEYS and (v == "" or v == "••••••"):
continue # keep existing secret
existing[k] = v
d.config = json.dumps(existing)
session.add(d)
session.commit()
session.refresh(d)
audit_service.record(
session, user=user.username, action="destination.update", target=d.name,
ip=_ip(request),
)
return _to_read(d)
@router.delete("/{dest_id}")
def delete_destination(
dest_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
name = d.name
session.delete(d)
session.commit()
audit_service.record(
session, user=user.username, action="destination.delete", target=name,
ip=_ip(request),
)
return {"ok": True}
@router.post("/{dest_id}/test")
async def test_destination(
dest_id: int,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
try:
await asyncio.to_thread(dest_service.test, d)
return {"ok": True}
except dest_service.DestinationError as exc:
return {"ok": False, "error": str(exc)}
@router.get("/{dest_id}/backups")
async def list_destination_backups(
dest_id: int,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list[dict]:
d = _get_or_404(session, dest_id)
try:
return await asyncio.to_thread(dest_service.list_backups, d)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.delete("/{dest_id}/backups/{name}")
async def delete_destination_backup(
dest_id: int,
name: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
try:
await asyncio.to_thread(dest_service.delete, d, name)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
audit_service.record(
session, user=user.username, action="destination.backup.delete",
target=f"{d.name}/{name}", ip=_ip(request),
)
return {"ok": True}