A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.
New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.
- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
take include_binds/binds/volumes, restore takes restore_binds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
8.9 KiB
Python
262 lines
8.9 KiB
Python
"""Stack backup (incl. volumes) and restore."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
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
|
|
|
|
|
|
def _compact(report: dict) -> dict:
|
|
"""The parts of a backup report worth showing the user."""
|
|
return {
|
|
"size": report.get("size"),
|
|
"binds": report.get("binds", []),
|
|
"volumes": report.get("volumes", []),
|
|
"skipped": report.get("skipped", []),
|
|
"path_mismatch": report.get("path_mismatch"),
|
|
}
|
|
|
|
|
|
def _summary(report: dict) -> str:
|
|
return (
|
|
f"binds={len(report.get('binds', []))} "
|
|
f"volumes={len(report.get('volumes', []))} "
|
|
f"skipped={len(report.get('skipped', []))}"
|
|
)
|
|
|
|
|
|
@router.get("/{stack_id}/backup/inventory")
|
|
async def backup_inventory(
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""What a backup of this stack would capture: bind-mount sources (with size
|
|
and whether they are reachable at all), named volumes, and anything that is
|
|
skipped by default with the reason why."""
|
|
if not session.get(Stack, stack_id):
|
|
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
|
return await asyncio.to_thread(backup_service.plan, stack_id)
|
|
|
|
|
|
@router.get("/{stack_id}/backup")
|
|
async def backup_stack(
|
|
stack_id: str,
|
|
request: Request,
|
|
include_volumes: bool = Query(True),
|
|
include_binds: bool = Query(True),
|
|
stop_first: bool = Query(True),
|
|
binds: list[str] | None = Query(None),
|
|
volumes: list[str] | None = Query(None),
|
|
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, report = await backup_service.create_backup_ex(
|
|
stack_id, stack.name, include_volumes=include_volumes,
|
|
stop_first=stop_first, include_binds=include_binds,
|
|
binds=binds, volumes=volumes,
|
|
)
|
|
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=_summary(report), ip=_ip(request),
|
|
)
|
|
return FileResponse(
|
|
path,
|
|
media_type="application/gzip",
|
|
filename=_backup_filename(stack_id, include_volumes),
|
|
# The browser downloads a blob, so the summary of what actually made it
|
|
# into the archive rides along in a header.
|
|
headers={"X-Stackpilot-Backup": json.dumps(_compact(report))},
|
|
)
|
|
|
|
|
|
@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),
|
|
restore_binds: 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,
|
|
restore_binds=restore_binds,
|
|
)
|
|
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']} binds={result['binds_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
|
|
include_binds: bool = True
|
|
stop_first: bool = True
|
|
binds: list[str] | None = None
|
|
volumes: list[str] | None = None
|
|
|
|
|
|
class RestoreFromBody(BaseModel):
|
|
destination_id: int
|
|
name: str
|
|
target_id: str | None = None
|
|
overwrite: bool = False
|
|
restore_volumes: bool = True
|
|
restore_binds: 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, report = await backup_service.create_backup_ex(
|
|
stack_id, stack.name,
|
|
include_volumes=body.include_volumes, stop_first=body.stop_first,
|
|
include_binds=body.include_binds, binds=body.binds, volumes=body.volumes,
|
|
)
|
|
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} {_summary(report)}", ip=_ip(request),
|
|
)
|
|
return {
|
|
"ok": True, "destination": dest.name, "name": filename,
|
|
"remote": remote, "report": _compact(report),
|
|
}
|
|
|
|
|
|
@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,
|
|
restore_binds=body.restore_binds,
|
|
)
|
|
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)
|