diff --git a/README.md b/README.md index 33f4d6e..392b77b 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. from its container labels) — the helper survives the backend being recreated; the UI polls `/api/health` and reloads when the new version answers. Installs not managed by compose get a clear "update manually" error instead. -- **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents - (snapshotted via a throwaway helper container); restore via upload with optional - rename, volume restore, and overwrite/conflict detection. +- **Backup & restore**: per-stack `.tar.gz` backups covering the whole stack — + the stack folder, every **bind-mounted data directory** (`./config`, `/mnt/appdata/…`) + and every named volume. Bind sources and volumes are read through a throwaway + helper container, i.e. by **host path**, so data that StackPilot itself cannot + see is captured too (that is the case whenever `STACKS_HOST_DIR` differs from + the container's `STACKS_DIR` — compose then creates the data directories at the + container path *on the host*, and a naive backup would only find the compose + file). The Backup dialog shows the full inventory with sizes and lets you pick + what goes in; **NFS/CIFS-backed volumes are unchecked by default** because they + live on a NAS and restoring one would overwrite the share. Restore puts bind + folders back at their host paths and preserves permissions, ownership and + symlinks (PUID/PGID-based images such as the *arr suite need this), with + optional rename and overwrite/conflict detection. - **Notification webhooks**: ntfy, Discord, Slack, Gotify, or generic JSON, each subscribed to chosen events (image update available, stack start/stop/error, pull failed). Managed in **Settings → Notifications**; env `NOTIFY_WEBHOOKS` diff --git a/backend/agent_app.py b/backend/agent_app.py index 9a0ff9f..a319ded 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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 diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 440253d..e8ff166 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -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: diff --git a/backend/routers/backups.py b/backend/routers/backups.py index f2f87d8..3cf441c 100644 --- a/backend/routers/backups.py +++ b/backend/routers/backups.py @@ -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 diff --git a/backend/services/backup_service.py b/backend/services/backup_service.py index cdf5fea..2007741 100644 --- a/backend/services/backup_service.py +++ b/backend/services/backup_service.py @@ -1,14 +1,16 @@ -"""Stack backup & restore, including named-volume contents. +"""Stack backup & restore — compose files, bind-mount data and named volumes. A backup is a single ``.tar.gz`` with this layout:: - manifest.json metadata + volume/bind inventory - compose/... the full stack directory (compose file, .env, ...) - volumes/.tar raw contents of each compose-managed named volume + manifest.json metadata + full inventory of what was captured + compose/... the stack directory as StackPilot can see it + binds/.tar contents of each captured bind-mount source + volumes/.tar contents of each captured named volume -Named-volume contents are read/written through a throwaway helper container -(``BACKUP_HELPER_IMAGE``) with the volume bind-mounted — this is the portable -way to snapshot a volume regardless of its driver/mountpoint. +Bind sources and volumes are read/written through a throwaway helper container +(see :mod:`services.stack_assets_service`) so that host paths this container +cannot see are still captured — without that, a stack whose data directories +live outside StackPilot's own mount would back up as "just the compose file". """ from __future__ import annotations @@ -22,16 +24,14 @@ import tarfile import tempfile from typing import Optional -from config import settings -from docker_client import DockerError, get_client, safe_call -from services import compose_service +from services import compose_service, stack_assets_service as assets logger = logging.getLogger("stackpilot.backup") -COMPOSE_PROJECT_LABEL = "com.docker.compose.project" -COMPOSE_VOLUME_LABEL = "com.docker.compose.volume" +COMPOSE_PROJECT_LABEL = assets.COMPOSE_PROJECT_LABEL +COMPOSE_VOLUME_LABEL = assets.COMPOSE_VOLUME_LABEL MANIFEST_NAME = "manifest.json" -BACKUP_FORMAT_VERSION = 1 +BACKUP_FORMAT_VERSION = 2 class BackupError(Exception): @@ -59,98 +59,48 @@ def backup_filename(stack_id: str, include_volumes: bool, prefix: Optional[str] # --------------------------------------------------------------------------- # -# Helper container for volume I/O +# Selection # --------------------------------------------------------------------------- # -def _ensure_helper_image(client) -> None: - image = settings.BACKUP_HELPER_IMAGE - try: - safe_call(client.images.get, image) - except DockerError: - logger.info("Pulling backup helper image %s", image) - safe_call(client.images.pull, image) +def _decide( + items: list[dict], key: str, chosen: Optional[list[str]], enabled: bool +) -> list[dict]: + """Mark each inventory item ``selected`` (with a reason when it is not). + + ``chosen`` is an explicit list from the caller; without one the inventory's + own defaults apply (everything except system paths, oversized directories + and remote-backed volumes). + """ + for item in items: + if not enabled: + item["selected"], item["reason"] = False, "not requested" + elif chosen is not None: + selected = item[key] in chosen + item["selected"] = selected + item["reason"] = None if selected else "not selected" + else: + item["selected"] = bool(item.get("include_default")) + item["reason"] = None if item["selected"] else (item.get("reason") or "not selected") + # Never archive plumbing, whatever the caller asked for. + if item["selected"] and (item.get("system") or item.get("kind") in ("special", "unknown")): + item["selected"] = False + item["reason"] = item.get("reason") or "not a regular file or directory" + return items -def _export_volume(full_name: str) -> bytes: - client = get_client() - _ensure_helper_image(client) - container = safe_call( - client.containers.create, - settings.BACKUP_HELPER_IMAGE, - command="true", - volumes={full_name: {"bind": "/v", "mode": "ro"}}, - ) - try: - # "/v/." copies the *contents* of the volume (no leading "v/" prefix), - # so restore can extract straight back into the volume root. - bits, _ = container.get_archive("/v/.") - buf = io.BytesIO() - for chunk in bits: - buf.write(chunk) - return buf.getvalue() - finally: - try: - container.remove(force=True) - except Exception: # noqa: BLE001 - pass - - -def _restore_volume(full_name: str, labels: dict, tar_bytes: bytes) -> None: - client = get_client() - _ensure_helper_image(client) - existed = True - try: - safe_call(client.volumes.get, full_name) - except DockerError: - existed = False - safe_call(client.volumes.create, name=full_name, labels=labels or {}) - if existed: - # Restore means "back to the snapshot": clear the current contents so - # files created/kept since the backup don't survive underneath it. - safe_call( - client.containers.run, - settings.BACKUP_HELPER_IMAGE, - ["sh", "-c", "find /v -mindepth 1 -delete"], - volumes={full_name: {"bind": "/v", "mode": "rw"}}, - remove=True, - ) - container = safe_call( - client.containers.create, - settings.BACKUP_HELPER_IMAGE, - command="true", - volumes={full_name: {"bind": "/v", "mode": "rw"}}, - ) - try: - container.put_archive("/v", tar_bytes) - finally: - try: - container.remove(force=True) - except Exception: # noqa: BLE001 - pass - - -def _compose_volumes(stack_id: str) -> list[dict]: - """Return [{full, short, labels}] for compose-managed named volumes.""" - try: - client = get_client() - vols = safe_call( - client.volumes.list, - filters={"label": f"{COMPOSE_PROJECT_LABEL}={stack_id}"}, - ) - except DockerError: - return [] - out = [] - for v in vols: - labels = v.attrs.get("Labels") or {} - out.append( - { - "full": v.name, - "short": labels.get(COMPOSE_VOLUME_LABEL, v.name), - "labels": labels, - } - ) - return out +def plan( + stack_id: str, + include_volumes: bool = True, + include_binds: bool = True, + binds: Optional[list[str]] = None, + volumes: Optional[list[str]] = None, +) -> dict: + """Decide what a backup captures. Returned as-is by the inventory endpoint.""" + inv = assets.inventory(stack_id) + _decide(inv["binds"], "source", binds, include_binds) + _decide(inv["volumes"], "name", volumes, include_volumes) + return inv # --------------------------------------------------------------------------- # @@ -158,23 +108,47 @@ def _compose_volumes(stack_id: str) -> list[dict]: # --------------------------------------------------------------------------- # -async def create_backup( +def _compose_filter(excluded_prefixes: list[str]): + """Drop deselected bind directories from the compose/ tree (keep the mount + point itself, so the stack still starts after a restore).""" + + def _filter(info: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: + for prefix in excluded_prefixes: + if info.name.startswith(prefix + "/"): + return None + return info + + return _filter + + +async def create_backup_ex( stack_id: str, name: str, include_volumes: bool = True, stop_first: bool = True, -) -> str: - """Create a backup tar.gz and return its path on disk.""" + include_binds: bool = True, + binds: Optional[list[str]] = None, + volumes: Optional[list[str]] = None, +) -> tuple[str, dict]: + """Create a backup tar.gz. Returns (path, report).""" directory = compose_service.stack_dir(stack_id) if not os.path.isdir(directory): raise BackupError("Stack directory missing") - volumes = _compose_volumes(stack_id) if include_volumes else [] + selection = await asyncio.to_thread( + plan, stack_id, include_volumes, include_binds, binds, volumes + ) + # Selected bind sources this process cannot reach through the filesystem + # need their own archive; the rest already ride along in compose/. + cap_binds = [b for b in selection["binds"] if b["selected"] and b["via"] == "archive"] + cap_volumes = [v for v in selection["volumes"] if v["selected"]] + skipped_binds = [b for b in selection["binds"] if not b["selected"]] + skipped_volumes = [v for v in selection["volumes"] if not v["selected"]] - # For a consistent volume snapshot, stop the stack first — but only if it - # is actually running, so backing up a stopped stack doesn't start it. + # Consistent snapshot: stop the stack first — but only if it is actually + # running, so backing up a stopped stack doesn't start it. stopped = False - if include_volumes and stop_first and volumes: + if stop_first and (cap_volumes or cap_binds): if compose_service.compute_status(stack_id) not in ("stopped", "unknown"): try: await compose_service.stop(stack_id) @@ -182,34 +156,122 @@ async def create_backup( except Exception as exc: # noqa: BLE001 logger.warning("Could not stop %s before backup: %s", stack_id, exc) + workdir = tempfile.mkdtemp(prefix="sp-backup-") try: + # Archive bind sources and volumes into the work directory first, so a + # failure on one asset is reported instead of corrupting the tar. + for index, bind in enumerate(cap_binds): + bind["archive"] = f"binds/{index:03d}.tar" + part = os.path.join(workdir, f"bind-{index:03d}.tar") + try: + bind["bytes"] = await asyncio.to_thread( + assets.export_path, bind["source"], bind["kind"], part + ) + bind["_part"] = part + except Exception as exc: # noqa: BLE001 + logger.warning("Could not archive bind %s: %s", bind["source"], exc) + bind["archive"] = None + bind["error"] = str(exc) + + for index, vol in enumerate(cap_volumes): + vol["archive"] = f"volumes/{vol['name']}.tar" + part = os.path.join(workdir, f"vol-{index:03d}.tar") + try: + vol["bytes"] = await asyncio.to_thread(assets.export_volume, vol["name"], part) + vol["_part"] = part + except Exception as exc: # noqa: BLE001 + logger.warning("Could not archive volume %s: %s", vol["name"], exc) + vol["archive"] = None + vol["error"] = str(exc) + + # Deselected data that sits inside the stack folder must not sneak into + # the archive through compose/ (that is how a 200 GB downloads folder + # ends up in a "config only" backup). + excluded = [ + "compose/" + os.path.relpath(b["source"], directory) + for b in skipped_binds + if b.get("inside_stack_dir") + ] + manifest = { "format_version": BACKUP_FORMAT_VERSION, "stack_id": stack_id, "name": name, "created_at": compose_service.now().isoformat(), "include_volumes": include_volumes, - "volumes": [{"full": v["full"], "short": v["short"], "labels": v["labels"]} for v in volumes], + "include_binds": include_binds, + "stack_dir": directory, + "path_mismatch": selection.get("path_mismatch"), + # Volumes keep the v1 shape (full/short/labels) so older StackPilots + # can still read the manifest they care about. + "volumes": [ + { + "full": v["name"], + "short": v["short"], + "labels": v["labels"], + "archive": v.get("archive"), + "remote": v.get("remote", False), + "bytes": v.get("bytes"), + "error": v.get("error"), + } + for v in cap_volumes + if v.get("archive") + ], + "binds": [ + { + "source": b["source"], + "kind": b["kind"], + "mounts": b["mounts"], + "inside_stack_dir": b["inside_stack_dir"], + "archive": b.get("archive"), + "bytes": b.get("bytes"), + "error": b.get("error"), + } + for b in cap_binds + if b.get("archive") + ], + "skipped": [ + {"kind": "bind", "source": b["source"], "reason": b.get("reason")} + for b in skipped_binds + ] + + [ + {"kind": "volume", "name": v["name"], "reason": v.get("reason")} + for v in skipped_volumes + ], } tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") tmp.close() with tarfile.open(tmp.name, "w:gz") as tar: - # manifest data = json.dumps(manifest, indent=2).encode("utf-8") info = tarfile.TarInfo(MANIFEST_NAME) info.size = len(data) tar.addfile(info, io.BytesIO(data)) - # stack directory - tar.add(directory, arcname="compose") - # volume contents - for v in volumes: - vbytes = await asyncio.to_thread(_export_volume, v["full"]) - info = tarfile.TarInfo(f"volumes/{v['full']}.tar") - info.size = len(vbytes) - tar.addfile(info, io.BytesIO(vbytes)) - return tmp.name + tar.add(directory, arcname="compose", filter=_compose_filter(excluded)) + for bind in cap_binds: + if bind.get("_part"): + tar.add(bind["_part"], arcname=bind["archive"]) + for vol in cap_volumes: + if vol.get("_part"): + tar.add(vol["_part"], arcname=vol["archive"]) + + report = { + "file": tmp.name, + "binds": [ + {"source": b["source"], "bytes": b.get("bytes"), "error": b.get("error")} + for b in cap_binds + ], + "volumes": [ + {"name": v["name"], "bytes": v.get("bytes"), "error": v.get("error")} + for v in cap_volumes + ], + "skipped": manifest["skipped"], + "path_mismatch": selection.get("path_mismatch"), + "size": os.path.getsize(tmp.name), + } + return tmp.name, report finally: + shutil.rmtree(workdir, ignore_errors=True) if stopped: try: await compose_service.up(stack_id) @@ -217,6 +279,21 @@ async def create_backup( logger.warning("Could not restart %s after backup: %s", stack_id, exc) +async def create_backup( + stack_id: str, + name: str, + include_volumes: bool = True, + stop_first: bool = True, + include_binds: bool = True, + binds: Optional[list[str]] = None, + volumes: Optional[list[str]] = None, +) -> str: + path, _report = await create_backup_ex( + stack_id, name, include_volumes, stop_first, include_binds, binds, volumes + ) + return path + + # --------------------------------------------------------------------------- # # Restore # --------------------------------------------------------------------------- # @@ -224,33 +301,87 @@ async def create_backup( def read_manifest(tar_path: str) -> dict: with tarfile.open(tar_path, "r:gz") as tar: - member = tar.getmember(MANIFEST_NAME) + try: + member = tar.getmember(MANIFEST_NAME) + except KeyError as exc: + raise BackupError("Backup is missing its manifest") from exc fh = tar.extractfile(member) if fh is None: raise BackupError("Backup is missing its manifest") return json.loads(fh.read().decode("utf-8")) -def _safe_extract_compose(tar: tarfile.TarFile, dest_dir: str) -> None: - """Extract the ``compose/`` subtree into dest_dir, guarding path traversal.""" +def _safe_target(dest_dir: str, rel: str) -> str: + """Resolve a member path inside dest_dir, refusing traversal *and* writes + through a symlink planted earlier in the same archive.""" + root = os.path.abspath(dest_dir) + target = os.path.normpath(os.path.join(root, rel)) + if target != root and not target.startswith(root + os.sep): + raise BackupError(f"Refusing unsafe path in backup: {rel}") + parent = os.path.dirname(target) + if os.path.exists(parent): + real_parent = os.path.realpath(parent) + if real_parent != root and not real_parent.startswith(root + os.sep): + raise BackupError(f"Refusing unsafe path in backup: {rel}") + return target + + +def _apply_meta(path: str, member: tarfile.TarInfo) -> None: + """Restore mode/ownership/mtime — *arr-style images run as PUID/PGID and + break when their config comes back root-owned with default permissions.""" + try: + os.chmod(path, member.mode) + except OSError: + pass + try: + os.chown(path, member.uid, member.gid) + except (OSError, AttributeError): + pass + try: + os.utime(path, (member.mtime, member.mtime)) + except OSError: + pass + + +def _extract_tree(tar: tarfile.TarFile, prefix: str, dest_dir: str) -> None: + """Extract one subtree of the archive, preserving metadata and symlinks.""" os.makedirs(dest_dir, exist_ok=True) + dirs: list[tuple[str, tarfile.TarInfo]] = [] for member in tar.getmembers(): - if not member.name.startswith("compose/"): + if not member.name.startswith(prefix): continue - rel = member.name[len("compose/") :] + rel = member.name[len(prefix) :].lstrip("/") if not rel: continue - target = os.path.normpath(os.path.join(dest_dir, rel)) - if not target.startswith(os.path.abspath(dest_dir) + os.sep) and target != os.path.abspath(dest_dir): - raise BackupError(f"Refusing unsafe path in backup: {member.name}") + target = _safe_target(dest_dir, rel) if member.isdir(): os.makedirs(target, exist_ok=True) - elif member.isreg(): - os.makedirs(os.path.dirname(target), exist_ok=True) - src = tar.extractfile(member) - if src is not None: - with open(target, "wb") as out: - shutil.copyfileobj(src, out) + dirs.append((target, member)) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + if member.issym(): + if os.path.lexists(target): + os.unlink(target) + os.symlink(member.linkname, target) + continue # chmod/utime would follow the link + if member.islnk(): + source = _safe_target(dest_dir, member.linkname[len(prefix) :].lstrip("/")) + if os.path.exists(source): + if os.path.lexists(target): + os.unlink(target) + os.link(source, target) + continue + if not member.isreg(): + continue # devices/fifos/sockets are runtime artefacts + src = tar.extractfile(member) + if src is None: + continue + with open(target, "wb") as out: + shutil.copyfileobj(src, out) + _apply_meta(target, member) + # Directory metadata last: a 0500 directory would block writing its files. + for target, member in sorted(dirs, key=lambda d: len(d[0]), reverse=True): + _apply_meta(target, member) def restore_backup( @@ -258,8 +389,9 @@ def restore_backup( target_id: Optional[str] = None, overwrite: bool = False, restore_volumes: bool = True, + restore_binds: bool = True, ) -> dict: - """Restore a backup. Returns {stack_id, name, volumes_restored}.""" + """Restore a backup. Returns a report of what was written.""" manifest = read_manifest(tar_path) raw_id = target_id or manifest.get("stack_id") or "" if not raw_id.strip(): @@ -267,21 +399,63 @@ def restore_backup( # Slugify whichever id we end up using — the manifest comes from an # uploaded file, so its stack_id must never be able to escape STACKS_DIR. stack_id = compose_service.slugify(raw_id) + old_id = manifest.get("stack_id") or stack_id + old_dir = manifest.get("stack_dir") or "" directory = compose_service.stack_dir(stack_id) exists = os.path.isdir(directory) if exists and not overwrite: raise BackupError(f"Stack '{stack_id}' already exists") + volumes_restored = 0 + binds_restored = 0 + skipped: list[dict] = [] + with tarfile.open(tar_path, "r:gz") as tar: if exists: shutil.rmtree(directory) - _safe_extract_compose(tar, directory) + _extract_tree(tar, "compose/", directory) + + if restore_binds: + for bind in manifest.get("binds", []): + archive = bind.get("archive") + if not archive: + continue + try: + member = tar.getmember(archive) + except KeyError: + continue + source = bind["source"] + # A renamed stack must not write into the old stack's folder. + if old_dir and bind.get("inside_stack_dir"): + rel = os.path.relpath(source, old_dir) + source = os.path.normpath(os.path.join(directory, rel)) + if assets.is_system_path(source): + skipped.append({"kind": "bind", "source": source, "reason": "system path"}) + continue + fh = tar.extractfile(member) + if fh is None: + continue + with tempfile.NamedTemporaryFile(delete=False, suffix=".tar") as tmp: + shutil.copyfileobj(fh, tmp) + part = tmp.name + try: + assets.import_path(source, bind.get("kind", "dir"), part) + binds_restored += 1 + except Exception as exc: # noqa: BLE001 + logger.warning("Could not restore bind %s: %s", source, exc) + skipped.append({"kind": "bind", "source": source, "reason": str(exc)}) + finally: + os.unlink(part) + elif manifest.get("binds"): + skipped += [ + {"kind": "bind", "source": b["source"], "reason": "not requested"} + for b in manifest["binds"] + ] - volumes_restored = 0 if restore_volumes: for v in manifest.get("volumes", []): - member_name = f"volumes/{v['full']}.tar" + member_name = v.get("archive") or f"volumes/{v['full']}.tar" try: member = tar.getmember(member_name) except KeyError: @@ -293,13 +467,27 @@ def restore_backup( labels = dict(v.get("labels") or {}) labels[COMPOSE_PROJECT_LABEL] = stack_id full = v["full"] - if target_id and manifest.get("stack_id") and full.startswith(manifest["stack_id"] + "_"): - full = stack_id + full[len(manifest["stack_id"]):] - _restore_volume(full, labels, fh.read()) - volumes_restored += 1 + if target_id and old_id and full.startswith(old_id + "_"): + full = stack_id + full[len(old_id) :] + with tempfile.NamedTemporaryFile(delete=False, suffix=".tar") as tmp: + shutil.copyfileobj(fh, tmp) + part = tmp.name + try: + # Remote-backed volumes (NFS/CIFS) are never wiped: that + # would delete the share the volume points at. + assets.import_volume(full, labels, part, wipe=not v.get("remote", False)) + volumes_restored += 1 + except Exception as exc: # noqa: BLE001 + logger.warning("Could not restore volume %s: %s", full, exc) + skipped.append({"kind": "volume", "name": full, "reason": str(exc)}) + finally: + os.unlink(part) + skipped += manifest.get("skipped", []) return { "stack_id": stack_id, "name": manifest.get("name", stack_id), "volumes_restored": volumes_restored, + "binds_restored": binds_restored, + "skipped": skipped, } diff --git a/backend/services/stack_assets_service.py b/backend/services/stack_assets_service.py new file mode 100644 index 0000000..2b1eff2 --- /dev/null +++ b/backend/services/stack_assets_service.py @@ -0,0 +1,500 @@ +"""Inventory of everything a stack's data actually lives in. + +A stack is more than its compose file: bind-mounted directories (``./config``) +and named volumes hold the real state. StackPilot itself runs in a container, so +it can only *see* what is mounted into it — a bind source like +``/opt/stacks/arr-stack/gluetun`` may exist on the host and still be invisible +here (that happens whenever the stacks directory is mounted under a different +host path than ``STACKS_DIR``, because compose resolves ``./gluetun`` against the +path *inside* this container and the daemon then creates it at that same path on +the **host**). + +Everything in this module therefore reads and writes host paths through a +throwaway helper container: the daemon does the mounting, so the data is +reachable regardless of what StackPilot has mounted. That is what makes backups +complete instead of "just the compose file". +""" +from __future__ import annotations + +import logging +import os +import re +from typing import Optional + +import yaml + +from config import settings +from docker_client import DockerError, get_client, safe_call +from services import compose_service + +logger = logging.getLogger("stackpilot.assets") + +COMPOSE_PROJECT_LABEL = "com.docker.compose.project" +COMPOSE_SERVICE_LABEL = "com.docker.compose.service" +COMPOSE_VOLUME_LABEL = "com.docker.compose.volume" + +# Paths that are plumbing, never stack data. +SYSTEM_PATHS = { + "/var/run/docker.sock", + "/run/docker.sock", + "/etc/localtime", + "/etc/timezone", + "/etc/hosts", + "/etc/resolv.conf", +} +SYSTEM_PREFIXES = ("/dev", "/proc", "/sys", "/run", "/var/run", "/var/lib/docker") + +# Volume driver_opts types that point at storage which lives somewhere else +# entirely (a NAS). Pulling a media library through a tar.gz is never what the +# user wants, and *restoring* one would overwrite the share. +REMOTE_VOLUME_TYPES = {"nfs", "nfs4", "cifs", "smb", "smb3", "smbfs", "sshfs", "glusterfs"} + +# Bind directories larger than this are listed but not selected by default. +DEFAULT_MAX_BIND_BYTES = 2 * 1024**3 + +_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)") + + +class AssetError(Exception): + pass + + +# --------------------------------------------------------------------------- # +# Helper container primitives (host-path I/O) +# --------------------------------------------------------------------------- # + + +def _ensure_helper_image(client) -> None: + image = settings.BACKUP_HELPER_IMAGE + try: + safe_call(client.images.get, image) + except DockerError: + logger.info("Pulling helper image %s", image) + safe_call(client.images.pull, image) + + +def _create_helper(client, volumes: dict): + return safe_call( + client.containers.create, + settings.BACKUP_HELPER_IMAGE, + command="true", + volumes=volumes, + ) + + +def _remove(container) -> None: + try: + container.remove(force=True) + except Exception: # noqa: BLE001 - cleanup is best effort + pass + + +def _split(path: str) -> tuple[str, str]: + clean = path.rstrip("/") or "/" + return os.path.dirname(clean) or "/", os.path.basename(clean) + + +def inspect_paths(paths: list[str]) -> dict[str, dict]: + """Classify host paths ({path: {"kind", "size"}}) via one helper container. + + ``kind`` is dir / file / special; ``size`` is bytes (best effort, the walk is + capped so a huge media share can't stall the request). + """ + unique = [p for p in dict.fromkeys(paths) if p] + if not unique: + return {} + client = get_client() + _ensure_helper_image(client) + mounts = {p: {"bind": f"/m/{i}", "mode": "ro"} for i, p in enumerate(unique)} + script_parts = [] + for i in range(len(unique)): + script_parts.append( + f'd=/m/{i}; ' + f'if [ -d "$d" ]; then s=$(timeout 20 du -sk "$d" 2>/dev/null | cut -f1); ' + f'echo "{i} dir ${{s:-}}"; ' + f'elif [ -f "$d" ]; then echo "{i} file $(stat -c %s "$d" 2>/dev/null)"; ' + f'else echo "{i} special"; fi' + ) + script = "; ".join(script_parts) + try: + out = safe_call( + client.containers.run, + settings.BACKUP_HELPER_IMAGE, + ["sh", "-c", script], + volumes=mounts, + remove=True, + stdout=True, + stderr=False, + ) + except DockerError as exc: + logger.warning("Path inspection failed: %s", exc) + return {p: {"kind": "unknown", "size": None} for p in unique} + + result: dict[str, dict] = {p: {"kind": "unknown", "size": None} for p in unique} + for line in (out or b"").decode("utf-8", "replace").splitlines(): + parts = line.strip().split() + if len(parts) < 2 or not parts[0].isdigit(): + continue + idx = int(parts[0]) + if idx >= len(unique): + continue + kind = parts[1] + size: Optional[int] = None + if len(parts) > 2 and parts[2].isdigit(): + size = int(parts[2]) * 1024 if kind == "dir" else int(parts[2]) + result[unique[idx]] = {"kind": kind, "size": size} + return result + + +def export_path(source: str, kind: str, dest_file: str) -> int: + """Tar a host path (dir contents, or a single file) into ``dest_file``.""" + client = get_client() + _ensure_helper_image(client) + if kind == "file": + parent, base = _split(source) + if not base: + raise AssetError(f"Cannot archive {source}") + container = _create_helper(client, {parent: {"bind": "/src", "mode": "ro"}}) + member = f"/src/{base}" + else: + container = _create_helper(client, {source: {"bind": "/src", "mode": "ro"}}) + # "/src/." archives the *contents*, so restore can unpack straight back + # into the directory without a stray prefix. + member = "/src/." + written = 0 + try: + bits, _ = container.get_archive(member) + with open(dest_file, "wb") as fh: + for chunk in bits: + fh.write(chunk) + written += len(chunk) + finally: + _remove(container) + return written + + +def import_path(source: str, kind: str, src_file: str) -> None: + """Unpack an archive produced by :func:`export_path` back to its host path.""" + client = get_client() + _ensure_helper_image(client) + if kind == "file": + parent, _base = _split(source) + container = _create_helper(client, {parent: {"bind": "/dst", "mode": "rw"}}) + else: + container = _create_helper(client, {source: {"bind": "/dst", "mode": "rw"}}) + try: + with open(src_file, "rb") as fh: + container.put_archive("/dst", fh) + finally: + _remove(container) + + +def export_volume(full_name: str, dest_file: str) -> int: + """Stream a named volume's contents into ``dest_file`` (never into RAM).""" + client = get_client() + _ensure_helper_image(client) + container = _create_helper(client, {full_name: {"bind": "/v", "mode": "ro"}}) + written = 0 + try: + bits, _ = container.get_archive("/v/.") + with open(dest_file, "wb") as fh: + for chunk in bits: + fh.write(chunk) + written += len(chunk) + finally: + _remove(container) + return written + + +def import_volume(full_name: str, labels: dict, src_file: str, wipe: bool = True) -> None: + """Restore a volume from an archive, optionally clearing it first.""" + client = get_client() + _ensure_helper_image(client) + existed = True + try: + safe_call(client.volumes.get, full_name) + except DockerError: + existed = False + safe_call(client.volumes.create, name=full_name, labels=labels or {}) + if existed and wipe: + # Restore means "back to the snapshot": drop files created since. + safe_call( + client.containers.run, + settings.BACKUP_HELPER_IMAGE, + ["sh", "-c", "find /v -mindepth 1 -delete"], + volumes={full_name: {"bind": "/v", "mode": "rw"}}, + remove=True, + ) + container = _create_helper(client, {full_name: {"bind": "/v", "mode": "rw"}}) + try: + with open(src_file, "rb") as fh: + container.put_archive("/v", fh) + finally: + _remove(container) + + +# --------------------------------------------------------------------------- # +# Where does STACKS_DIR really live on the host? +# --------------------------------------------------------------------------- # + + +def host_stacks_dir() -> Optional[str]: + """Host path backing ``STACKS_DIR`` inside this container, if detectable. + + Read from /proc/self/mountinfo (field 4 is the source subtree on the host + filesystem). Returns None when not running in a container / not bind-mounted. + """ + target = settings.STACKS_DIR.rstrip("/") or "/" + try: + with open("/proc/self/mountinfo", "r", encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) < 5: + continue + if parts[4].rstrip("/") == target: + return parts[3] + except OSError: + return None + return None + + +def stacks_path_mismatch() -> Optional[dict]: + """Report a host/container path mismatch for the stacks directory. + + When they differ, compose resolves a stack's relative bind mounts against + the *container* path, so the daemon creates the data directories at that + path on the host — invisible to StackPilot. Backups then only find the + compose file unless bind sources are captured through a helper container. + """ + host = host_stacks_dir() + container = settings.STACKS_DIR.rstrip("/") + if not host or host.rstrip("/") == container: + return None + return {"host": host, "container": container} + + +# --------------------------------------------------------------------------- # +# Compose / container mount discovery +# --------------------------------------------------------------------------- # + + +def _env_for_stack(stack_id: str) -> dict: + env: dict[str, str] = {} + path = os.path.join(compose_service.stack_dir(stack_id), ".env") + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip().strip('"').strip("'") + except OSError: + pass + return env + + +def _interpolate(text: str, env: dict) -> str: + def repl(m: re.Match) -> str: + name = m.group(1) or m.group(3) + default = m.group(2) or "" + return env.get(name, default) + + return _ENV_RE.sub(repl, text) + + +def _bind_specs_from_compose(stack_id: str) -> list[dict]: + """Bind sources declared in the compose file (used when no containers exist).""" + directory = compose_service.stack_dir(stack_id) + compose_file = compose_service.find_compose_file(directory) + if not compose_file: + return [] + try: + with open(compose_file, "r", encoding="utf-8", errors="replace") as fh: + data = yaml.safe_load(fh) or {} + except (OSError, yaml.YAMLError): + return [] + env = _env_for_stack(stack_id) + out: list[dict] = [] + for service, spec in (data.get("services") or {}).items(): + if not isinstance(spec, dict): + continue + for entry in spec.get("volumes") or []: + source = target = None + if isinstance(entry, str): + parts = _interpolate(entry, env).split(":") + if len(parts) >= 2: + source, target = parts[0], parts[1] + elif isinstance(entry, dict): + if entry.get("type") not in (None, "bind"): + continue + source = _interpolate(str(entry.get("source") or ""), env) + target = _interpolate(str(entry.get("target") or ""), env) + if not source or not target: + continue + if not (source.startswith("/") or source.startswith(".") or source.startswith("~")): + continue # named volume + if source.startswith("~"): + continue # home-relative: resolved by the daemon's user, skip + resolved = source if source.startswith("/") else os.path.normpath( + os.path.join(directory, source) + ) + out.append({"source": resolved, "service": str(service), "target": target}) + return out + + +def _bind_specs_from_containers(stack_id: str) -> list[dict]: + """Bind sources as the daemon actually mounted them (authoritative).""" + try: + client = get_client() + containers = safe_call( + client.containers.list, + all=True, + filters={"label": f"{COMPOSE_PROJECT_LABEL}={stack_id}"}, + ) + except DockerError: + return [] + out: list[dict] = [] + for c in containers: + service = (c.labels or {}).get(COMPOSE_SERVICE_LABEL, c.name) + for mount in c.attrs.get("Mounts") or []: + if mount.get("Type") != "bind" or not mount.get("Source"): + continue + out.append( + { + "source": mount["Source"], + "service": service, + "target": mount.get("Destination") or "", + } + ) + return out + + +def is_system_path(path: str) -> bool: + if path in SYSTEM_PATHS: + return True + return any(path == p or path.startswith(p + "/") for p in SYSTEM_PREFIXES) + + +def _inside(path: str, parent: str) -> bool: + parent = parent.rstrip("/") + return path == parent or path.startswith(parent + "/") + + +def compose_volumes(stack_id: str) -> list[dict]: + """Compose-managed named volumes, with remote-storage detection.""" + try: + client = get_client() + vols = safe_call( + client.volumes.list, + filters={"label": f"{COMPOSE_PROJECT_LABEL}={stack_id}"}, + ) + except DockerError: + return [] + out = [] + for v in vols: + attrs = v.attrs or {} + labels = attrs.get("Labels") or {} + options = attrs.get("Options") or {} + driver = attrs.get("Driver", "local") + vtype = str(options.get("type") or "").lower() + device = str(options.get("device") or "") + remote = ( + vtype in REMOTE_VOLUME_TYPES + or driver != "local" + or device.startswith("//") + or device.startswith(":") + ) + out.append( + { + "name": v.name, + "short": labels.get(COMPOSE_VOLUME_LABEL, v.name), + "labels": labels, + "driver": driver, + "options": options, + "remote": remote, + "remote_type": vtype or (driver if driver != "local" else None), + } + ) + return out + + +def inventory(stack_id: str, max_bind_bytes: int = DEFAULT_MAX_BIND_BYTES) -> dict: + """What a backup of this stack would (and would not) capture. + + Bind sources are merged from the running containers (authoritative) and the + compose file (covers stacks that were never started), classified through a + helper container so host-only paths are seen too. + """ + directory = compose_service.stack_dir(stack_id) + specs = _bind_specs_from_containers(stack_id) or [] + seen = {(s["source"], s["service"], s["target"]) for s in specs} + for spec in _bind_specs_from_compose(stack_id): + if (spec["source"], spec["service"], spec["target"]) not in seen: + specs.append(spec) + + grouped: dict[str, dict] = {} + for spec in specs: + entry = grouped.setdefault(spec["source"], {"source": spec["source"], "mounts": []}) + mount = {"service": spec["service"], "target": spec["target"]} + if mount not in entry["mounts"]: + entry["mounts"].append(mount) + + real_paths = [p for p in grouped if not is_system_path(p)] + stats = inspect_paths(real_paths) + + binds = [] + for path, entry in sorted(grouped.items()): + system = is_system_path(path) + info = stats.get(path, {"kind": "unknown", "size": None}) + kind, size = info["kind"], info["size"] + inside = _inside(path, directory) + # A path inside the stack directory that this process can actually read + # is already covered by the compose/ tree in the archive. + visible = inside and os.path.exists(path) + include = True + reason = None + if system: + include, reason = False, "system path" + elif kind == "special": + include, reason = False, "not a regular file or directory" + elif kind == "unknown": + include, reason = False, "could not inspect path" + elif size is not None and size > max_bind_bytes: + include, reason = False, f"larger than {max_bind_bytes // 1024**3} GiB" + binds.append( + { + "source": path, + "mounts": entry["mounts"], + "kind": kind, + "size": size, + "inside_stack_dir": inside, + # Readable from here and inside the stack folder → the compose/ + # tree already carries it, no separate archive needed. + "covered_by_compose": visible, + "via": "compose" if visible else "archive", + "system": system, + "include_default": include, + "reason": reason, + } + ) + + volumes = [] + for vol in compose_volumes(stack_id): + include = not vol["remote"] + volumes.append( + { + **vol, + "include_default": include, + "reason": None if include else f"remote storage ({vol['remote_type']})", + } + ) + + return { + "stack_id": stack_id, + "stack_dir": directory, + "stack_dir_visible": os.path.isdir(directory), + "path_mismatch": stacks_path_mismatch(), + "binds": binds, + "volumes": volumes, + } diff --git a/backend/version.py b/backend/version.py index 2da3d1a..e3c867e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.39.0" +APP_VERSION = "0.40.0" diff --git a/frontend/package.json b/frontend/package.json index b71b7a8..1229127 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.39.0", + "version": "0.40.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index f99b16e..c217b4f 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -1,4 +1,6 @@ import api from "./client"; +import { backupParams, readReport } from "./backups"; +import type { BackupInventory, BackupOptions, RestoreResult } from "./backups"; import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types"; export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string }; @@ -54,13 +56,13 @@ export const agentsApi = { action: (id: number, stackId: string, action: string) => api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data), - backupDownload: async ( - id: number, - stackId: string, - opts: { includeVolumes: boolean; stopFirst: boolean } - ) => { + backupInventory: (id: number, stackId: string) => + api + .get(`/api/agents/${id}/stacks/${stackId}/backup/inventory`) + .then((r) => r.data), + backupDownload: async (id: number, stackId: string, opts: BackupOptions) => { const res = await api.get(`/api/agents/${id}/stacks/${stackId}/backup`, { - params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst }, + params: backupParams(opts), responseType: "blob", }); const cd = res.headers["content-disposition"] as string | undefined; @@ -73,11 +75,19 @@ export const agentsApi = { a.click(); a.remove(); URL.revokeObjectURL(url); + return readReport(res.headers); }, backupPush: ( id: number, stackId: string, - body: { destination_id: number; include_volumes: boolean; stop_first: boolean } + body: { + destination_id: number; + include_volumes: boolean; + include_binds?: boolean; + stop_first: boolean; + binds?: string[]; + volumes?: string[]; + } ) => api .post<{ ok: boolean; destination: string; name: string }>( @@ -88,18 +98,21 @@ export const agentsApi = { restoreUpload: ( id: number, file: File, - opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean } + opts: { + targetId?: string; + overwrite: boolean; + restoreVolumes: boolean; + restoreBinds?: boolean; + } ) => { const form = new FormData(); form.append("file", file); if (opts.targetId) form.append("target_id", opts.targetId); form.append("overwrite", String(opts.overwrite)); form.append("restore_volumes", String(opts.restoreVolumes)); + form.append("restore_binds", String(opts.restoreBinds ?? true)); return api - .post<{ stack_id: string; name: string; volumes_restored: number }>( - `/api/agents/${id}/stacks/restore`, - form - ) + .post(`/api/agents/${id}/stacks/restore`, form) .then((r) => r.data); }, restoreFrom: ( @@ -110,12 +123,10 @@ export const agentsApi = { target_id?: string; overwrite: boolean; restore_volumes: boolean; + restore_binds?: boolean; } ) => api - .post<{ stack_id: string; name: string; volumes_restored: number }>( - `/api/agents/${id}/stacks/restore-from`, - body - ) + .post(`/api/agents/${id}/stacks/restore-from`, body) .then((r) => r.data), }; diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts index 6711494..ceff2c7 100644 --- a/frontend/src/api/backups.ts +++ b/frontend/src/api/backups.ts @@ -8,6 +8,87 @@ export interface BackupDestination { created_at: string; } +/** One bind-mount source or named volume a backup would capture. */ +export interface BackupBind { + source: string; + mounts: { service: string; target: string }[]; + kind: string; + size: number | null; + inside_stack_dir: boolean; + covered_by_compose: boolean; + via: "compose" | "archive"; + system: boolean; + include_default: boolean; + selected: boolean; + reason: string | null; +} + +export interface BackupVolume { + name: string; + short: string; + driver: string; + remote: boolean; + remote_type: string | null; + include_default: boolean; + selected: boolean; + reason: string | null; +} + +export interface BackupInventory { + stack_id: string; + stack_dir: string; + stack_dir_visible: boolean; + path_mismatch: { host: string; container: string } | null; + binds: BackupBind[]; + volumes: BackupVolume[]; +} + +export interface BackupReport { + size: number | null; + binds: { source: string; bytes: number | null; error: string | null }[]; + volumes: { name: string; bytes: number | null; error: string | null }[]; + skipped: { kind: string; source?: string; name?: string; reason: string | null }[]; + path_mismatch: { host: string; container: string } | null; +} + +export interface RestoreResult { + stack_id: string; + name: string; + volumes_restored: number; + binds_restored?: number; + skipped?: { kind: string; source?: string; name?: string; reason: string | null }[]; +} + +export interface BackupOptions { + includeVolumes: boolean; + stopFirst: boolean; + includeBinds?: boolean; + binds?: string[]; + volumes?: string[]; +} + +/** Query params shared by the local and the agent-proxied backup endpoints. */ +export function backupParams(opts: BackupOptions) { + return { + include_volumes: opts.includeVolumes, + include_binds: opts.includeBinds ?? true, + stop_first: opts.stopFirst, + ...(opts.binds ? { binds: opts.binds } : {}), + ...(opts.volumes ? { volumes: opts.volumes } : {}), + }; +} + +/** The backup summary the server attaches to the download response. */ +export function readReport(headers: unknown): BackupReport | null { + const raw = (headers as Record | undefined)?.["x-stackpilot-backup"]; + if (!raw) return null; + try { + return JSON.parse(raw) as BackupReport; + } catch { + return null; + } +} + export interface RemoteBackup { name: string; size: number; @@ -26,46 +107,60 @@ function triggerDownload(blob: Blob, filename: string) { } export const backupsApi = { - download: async ( - stackId: string, - opts: { includeVolumes: boolean; stopFirst: boolean } - ) => { + inventory: (stackId: string) => + api + .get(`/api/stacks/${stackId}/backup/inventory`) + .then((r) => r.data), + + download: async (stackId: string, opts: BackupOptions) => { const res = await api.get(`/api/stacks/${stackId}/backup`, { - params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst }, + params: backupParams(opts), responseType: "blob", }); const cd = res.headers["content-disposition"] as string | undefined; const match = cd?.match(/filename="?([^"]+)"?/); const name = match?.[1] ?? `backup-${stackId}.tar.gz`; triggerDownload(res.data as Blob, name); + return readReport(res.headers); }, restore: async ( file: File, - opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean } + opts: { + targetId?: string; + overwrite: boolean; + restoreVolumes: boolean; + restoreBinds?: boolean; + } ) => { const form = new FormData(); form.append("file", file); if (opts.targetId) form.append("target_id", opts.targetId); form.append("overwrite", String(opts.overwrite)); form.append("restore_volumes", String(opts.restoreVolumes)); - const res = await api.post<{ - stack_id: string; - name: string; - volumes_restored: number; - }>("/api/stacks/restore", form); + form.append("restore_binds", String(opts.restoreBinds ?? true)); + const res = await api.post("/api/stacks/restore", form); return res.data; }, push: ( stackId: string, - body: { destination_id: number; include_volumes: boolean; stop_first: boolean } + body: { + destination_id: number; + include_volumes: boolean; + include_binds?: boolean; + stop_first: boolean; + binds?: string[]; + volumes?: string[]; + } ) => api - .post<{ ok: boolean; destination: string; name: string }>( - `/api/stacks/${stackId}/backup/push`, - body - ) + .post<{ + ok: boolean; + destination: string; + name: string; + report?: BackupReport; + }>(`/api/stacks/${stackId}/backup/push`, body) .then((r) => r.data), restoreFrom: (body: { @@ -74,13 +169,8 @@ export const backupsApi = { target_id?: string; overwrite: boolean; restore_volumes: boolean; - }) => - api - .post<{ stack_id: string; name: string; volumes_restored: number }>( - "/api/stacks/restore-from", - body - ) - .then((r) => r.data), + restore_binds?: boolean; + }) => api.post("/api/stacks/restore-from", body).then((r) => r.data), }; export const destinationsApi = { diff --git a/frontend/src/components/stacks/BackupRestore.tsx b/frontend/src/components/stacks/BackupRestore.tsx index 880c01f..c385a9d 100644 --- a/frontend/src/components/stacks/BackupRestore.tsx +++ b/frontend/src/components/stacks/BackupRestore.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Archive, Upload } from "lucide-react"; +import { AlertTriangle, Archive, Upload } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui"; import { backupsApi, destinationsApi } from "@/api/backups"; +import type { BackupReport } from "@/api/backups"; import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { formatBytes } from "@/lib/utils"; @@ -38,11 +39,21 @@ function Checkbox({ ); } -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { +function Modal({ + children, + onClose, + wide, +}: { + children: React.ReactNode; + onClose: () => void; + wide?: boolean; +}) { return (
e.stopPropagation()} > {children} @@ -51,6 +62,61 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () = ); } +function AssetRow({ + checked, + onChange, + title, + detail, + size, + badges, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + title: string; + detail?: string; + size?: number | null; + badges?: { text: string; tone?: "muted" | "warn" }[]; + disabled?: boolean; +}) { + return ( + + ); +} + export function BackupButton({ stackId, agentId, @@ -59,39 +125,75 @@ export function BackupButton({ agentId?: number; }) { const [open, setOpen] = useState(false); - const [includeVolumes, setIncludeVolumes] = useState(true); const [stopFirst, setStopFirst] = useState(true); const [target, setTarget] = useState("download"); // "download" | destination id const [busy, setBusy] = useState(false); + // null = "use the inventory defaults" (until the user touches a checkbox). + const [pickedBinds, setPickedBinds] = useState(null); + const [pickedVolumes, setPickedVolumes] = useState(null); const destinations = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list, enabled: open, }); + const inventory = useQuery({ + queryKey: ["backup-inventory", agentId ?? "local", stackId], + queryFn: () => + agentId != null + ? agentsApi.backupInventory(agentId, stackId) + : backupsApi.inventory(stackId), + enabled: open, + }); + + const binds = inventory.data?.binds ?? []; + const volumes = inventory.data?.volumes ?? []; + const bindSel = pickedBinds ?? binds.filter((b) => b.include_default).map((b) => b.source); + const volSel = pickedVolumes ?? volumes.filter((v) => v.include_default).map((v) => v.name); + const toggle = (list: string[], value: string, on: boolean) => + on ? [...list, value] : list.filter((v) => v !== value); + + const describe = (report: BackupReport | null | undefined) => { + if (!report) return "Backup created"; + const parts = [`${report.binds.length} folder(s)`, `${report.volumes.length} volume(s)`]; + if (report.size) parts.push(formatBytes(report.size)); + const skipped = report.skipped.filter((s) => s.reason !== "not requested").length; + return `Backup: ${parts.join(" · ")}${skipped ? ` — ${skipped} skipped` : ""}`; + }; const run = async () => { setBusy(true); const tid = toast.loading("Creating backup…"); try { + const opts = { + includeVolumes: volSel.length > 0, + includeBinds: bindSel.length > 0, + stopFirst, + // Empty lists would be dropped from the query string and read as + // "use defaults", so the include_* flags carry that case. + binds: bindSel.length ? bindSel : undefined, + volumes: volSel.length ? volSel : undefined, + }; if (target === "download") { - if (agentId != null) { - await agentsApi.backupDownload(agentId, stackId, { includeVolumes, stopFirst }); - } else { - await backupsApi.download(stackId, { includeVolumes, stopFirst }); - } - toast.success("Backup downloaded", { id: tid }); + const report = + agentId != null + ? await agentsApi.backupDownload(agentId, stackId, opts) + : await backupsApi.download(stackId, opts); + toast.success(describe(report), { id: tid }); } else { const body = { destination_id: Number(target), - include_volumes: includeVolumes, + include_volumes: opts.includeVolumes, + include_binds: opts.includeBinds, stop_first: stopFirst, + binds: opts.binds, + volumes: opts.volumes, }; const res = agentId != null ? await agentsApi.backupPush(agentId, stackId, body) : await backupsApi.push(stackId, body); - toast.success(`Backup pushed to ${res.destination}`, { id: tid }); + toast.success(`Pushed to ${res.destination}`, { id: tid }); } setOpen(false); } catch (e) { @@ -107,9 +209,75 @@ export function BackupButton({ Backup {open && ( - !busy && setOpen(false)}> + !busy && setOpen(false)}>

Back up “{stackId}”

+ {inventory.data?.path_mismatch && ( +
+ + + This stack's folder is {inventory.data.path_mismatch.container} inside + StackPilot but {inventory.data.path_mismatch.host} on the host, so the + containers' data directories are not visible here. They are read through a helper + container and are listed below. + +
+ )} + +
+
Contents
+
+ {}} + title={inventory.data?.stack_dir ?? "stack folder"} + detail="Compose file, .env and everything else in the stack folder" + badges={[{ text: "always included" }]} + /> + {inventory.isLoading && ( +
Scanning stack data…
+ )} + {binds.map((b) => ( + setPickedBinds(toggle(bindSel, b.source, v))} + title={b.source} + detail={b.mounts.map((m) => `${m.service}:${m.target}`).join(", ")} + size={b.size} + badges={[ + ...(b.via === "compose" ? [{ text: "in stack folder" as const }] : []), + ...(b.reason ? [{ text: b.reason, tone: "warn" as const }] : []), + ]} + /> + ))} + {volumes.map((v) => ( + setPickedVolumes(toggle(volSel, v.name, on))} + title={v.name} + detail={`named volume (${v.driver})`} + badges={[ + ...(v.remote ? [{ text: v.remote_type || "remote", tone: "warn" as const }] : []), + ...(v.reason && !v.remote ? [{ text: v.reason, tone: "warn" as const }] : []), + ]} + /> + ))} + {!inventory.isLoading && binds.length === 0 && volumes.length === 0 && ( +
+ No bind mounts or named volumes found for this stack. +
+ )} +
+

+ Remote storage (NFS/CIFS) is unchecked by default — it lives on your NAS and would be + overwritten on restore. +

+
+ -
@@ -158,6 +320,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) { const [targetId, setTargetId] = useState(""); const [overwrite, setOverwrite] = useState(false); const [restoreVolumes, setRestoreVolumes] = useState(true); + const [restoreBinds, setRestoreBinds] = useState(true); const [busy, setBusy] = useState(false); const destinations = useQuery({ @@ -182,7 +345,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) { setBusy(false); return; } - const opts = { targetId: targetId.trim() || undefined, overwrite, restoreVolumes }; + const opts = { + targetId: targetId.trim() || undefined, + overwrite, + restoreVolumes, + restoreBinds, + }; res = agentId != null ? await agentsApi.restoreUpload(agentId, file, opts) @@ -199,13 +367,16 @@ export function RestoreButton({ agentId }: { agentId?: number }) { target_id: targetId.trim() || undefined, overwrite, restore_volumes: restoreVolumes, + restore_binds: restoreBinds, }; res = agentId != null ? await agentsApi.restoreFrom(agentId, body) : await backupsApi.restoreFrom(body); } - toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid }); + const bits = [`${res.volumes_restored} volume(s)`]; + if (res.binds_restored) bits.push(`${res.binds_restored} folder(s)`); + toast.success(`Restored '${res.stack_id}' — ${bits.join(", ")}`, { id: tid }); qc.invalidateQueries({ queryKey: agentId != null ? ["agent-stacks", agentId] : ["stacks"] }); setOpen(false); setFile(null); @@ -305,6 +476,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) { /> +