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
+23 -2
View File
@@ -10,6 +10,7 @@ All compose/Docker logic is reused from the backend's ``compose_service`` and
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
@@ -464,17 +465,28 @@ def agent_detach_secret(stack_id: str, body: SecretDetachBody) -> dict:
return {"ok": True, "yaml": new_yaml}
@app.get("/agent/stacks/{stack_id}/backup/inventory", dependencies=[Depends(verify_token)])
async def backup_inventory(stack_id: str) -> dict:
"""What a backup of this stack would capture (see routers/backups.py)."""
_ensure_stack(stack_id)
return await asyncio.to_thread(backup_service.plan, stack_id)
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
async def backup_stack(
stack_id: str,
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),
):
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
try:
path = await backup_service.create_backup(
path, report = await backup_service.create_backup_ex(
stack_id, stack_id, 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
@@ -482,6 +494,13 @@ async def backup_stack(
path,
media_type="application/gzip",
filename=backup_service.backup_filename(stack_id, include_volumes),
headers={"X-Stackpilot-Backup": json.dumps({
"size": report.get("size"),
"binds": report.get("binds", []),
"volumes": report.get("volumes", []),
"skipped": report.get("skipped", []),
"path_mismatch": report.get("path_mismatch"),
})},
)
@@ -491,6 +510,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),
) -> dict:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
try:
@@ -500,7 +520,8 @@ async def restore_stack(
target = compose_service.slugify(target_id) if target_id else None
try:
return backup_service.restore_backup(
tmp.name, target_id=target, overwrite=overwrite, restore_volumes=restore_volumes,
tmp.name, target_id=target, overwrite=overwrite,
restore_volumes=restore_volumes, restore_binds=restore_binds,
)
except backup_service.BackupError as exc:
code = 409 if "already exists" in str(exc) else 400