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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user