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:
menzelj
2026-08-16 18:20:19 +00:00
co-authored by Claude Opus 5
parent ecf780c5e6
commit 5347a36eaf
11 changed files with 1301 additions and 217 deletions
+35 -2
View File
@@ -39,7 +39,10 @@ _ACTIONS = {"start", "stop", "restart", "pull", "update", "down"}
class AgentPushBody(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 AgentRestoreFromBody(BaseModel):
@@ -47,6 +50,7 @@ class AgentRestoreFromBody(BaseModel):
name: str
target_id: str | None = None
overwrite: bool = False
restore_binds: bool = True
restore_volumes: bool = True
@@ -351,13 +355,27 @@ async def agent_lifecycle(
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/backup/inventory")
async def agent_backup_inventory(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/backup/inventory")
@router.get("/{agent_id}/stacks/{stack_id}/backup")
async def agent_backup_download(
agent_id: int,
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),
):
@@ -367,7 +385,13 @@ async def agent_backup_download(
try:
await agent_service.download_to_file(
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
params={"include_volumes": include_volumes, "stop_first": stop_first},
params={
"include_volumes": include_volumes,
"include_binds": include_binds,
"stop_first": stop_first,
**({"binds": binds} if binds else {}),
**({"volumes": volumes} if volumes else {}),
},
)
except AgentError as exc:
if os.path.exists(tmp.name):
@@ -403,7 +427,13 @@ async def agent_backup_push(
try:
await agent_service.download_to_file(
session, agent, f"/agent/stacks/{stack_id}/backup", tmp.name,
params={"include_volumes": body.include_volumes, "stop_first": body.stop_first},
params={
"include_volumes": body.include_volumes,
"include_binds": body.include_binds,
"stop_first": body.stop_first,
**({"binds": body.binds} if body.binds else {}),
**({"volumes": body.volumes} if body.volumes else {}),
},
)
except AgentError as exc:
_raise(exc)
@@ -432,6 +462,7 @@ async def agent_restore_upload(
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:
@@ -448,6 +479,7 @@ async def agent_restore_upload(
"target_id": target_id or "",
"overwrite": str(overwrite).lower(),
"restore_volumes": str(restore_volumes).lower(),
"restore_binds": str(restore_binds).lower(),
},
)
except AgentError as exc:
@@ -485,6 +517,7 @@ async def agent_restore_from(
"target_id": body.target_id or "",
"overwrite": str(body.overwrite).lower(),
"restore_volumes": str(body.restore_volumes).lower(),
"restore_binds": str(body.restore_binds).lower(),
},
)
except AgentError as exc: