Back up bind-mount data, not just the compose file (0.40.0)
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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
@@ -32,12 +33,48 @@ def _ip(request: Request) -> str:
|
||||
_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),
|
||||
):
|
||||
@@ -45,19 +82,24 @@ async def backup_stack(
|
||||
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,
|
||||
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=f"volumes={include_volumes}", ip=_ip(request),
|
||||
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))},
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +110,7 @@ async def restore_stack(
|
||||
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:
|
||||
@@ -84,6 +127,7 @@ async def restore_stack(
|
||||
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.
|
||||
@@ -97,7 +141,8 @@ async def restore_stack(
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.restore", target=stack_id,
|
||||
detail=f"volumes={result['volumes_restored']}", ip=_ip(request),
|
||||
detail=f"volumes={result['volumes_restored']} binds={result['binds_restored']}",
|
||||
ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
@@ -113,7 +158,10 @@ async def restore_stack(
|
||||
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):
|
||||
@@ -122,6 +170,7 @@ class RestoreFromBody(BaseModel):
|
||||
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:
|
||||
@@ -144,9 +193,10 @@ async def push_backup(
|
||||
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(
|
||||
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
|
||||
@@ -162,9 +212,12 @@ async def push_backup(
|
||||
|
||||
audit_service.record(
|
||||
session, user=user.username, action="stack.backup.push",
|
||||
target=stack_id, detail=f"{dest.name}:{filename}", ip=_ip(request),
|
||||
target=stack_id, detail=f"{dest.name}:{filename} {_summary(report)}", ip=_ip(request),
|
||||
)
|
||||
return {"ok": True, "destination": dest.name, "name": filename, "remote": remote}
|
||||
return {
|
||||
"ok": True, "destination": dest.name, "name": filename,
|
||||
"remote": remote, "report": _compact(report),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/restore-from")
|
||||
@@ -188,6 +241,7 @@ async def restore_from_destination(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user