From 79d82361d8b3af5d2650819c7967d3ee7ceffd72 Mon Sep 17 00:00:00 2001 From: menzelj Date: Fri, 12 Jun 2026 12:10:45 +0000 Subject: [PATCH] 0.32.1: backup/restore fixes (audit findings, all paths live-verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backup_filename() crashed with NameError (bare now()) since 0.8.0 — broke every scheduled backup at the upload step, agent backup download and the central remote-backup/push endpoints. The local manual path worked only because the router had its own copy (now an alias). - restore: the manifest stack_id from an uploaded backup is now slugified too — a crafted '../../...' id could previously escape STACKS_DIR. - create_backup no longer starts a previously-stopped stack (stop/restart only when the stack was actually running). - overwrite-restore wipes the existing volume contents before extracting, so files created since the backup no longer survive underneath it. Verified end-to-end: full/config backup contents (compose, .env, .secrets, bind dirs, extras, volume tars), delete→restore round-trip incl. volume data, rename restore with volume re-prefixing, 409 conflict + overwrite, traversal guard, scheduled run + retention prune + restore-from against real MinIO, and the complete remote-agent cycle (download/push/restore). Co-Authored-By: Claude Fable 5 --- backend/routers/backups.py | 5 +---- backend/services/backup_service.py | 35 ++++++++++++++++++++++-------- backend/version.py | 2 +- frontend/package.json | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/backend/routers/backups.py b/backend/routers/backups.py index ace14a0..f2f87d8 100644 --- a/backend/routers/backups.py +++ b/backend/routers/backups.py @@ -29,10 +29,7 @@ def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" -def _backup_filename(stack_id: str, include_volumes: bool) -> str: - date = compose_service.now().strftime("%Y%m%d-%H%M%S") - suffix = "full" if include_volumes else "config" - return f"backup-{stack_id}-{suffix}-{date}.tar.gz" +_backup_filename = backup_service.backup_filename @router.get("/{stack_id}/backup") diff --git a/backend/services/backup_service.py b/backend/services/backup_service.py index 8d6dde7..cdf5fea 100644 --- a/backend/services/backup_service.py +++ b/backend/services/backup_service.py @@ -53,7 +53,7 @@ def backup_basename(stack_id: str, prefix: Optional[str] = None) -> str: def backup_filename(stack_id: str, include_volumes: bool, prefix: Optional[str] = None) -> str: - date = now().strftime("%Y%m%d-%H%M%S") + date = compose_service.now().strftime("%Y%m%d-%H%M%S") suffix = "full" if include_volumes else "config" return f"{backup_basename(stack_id, prefix)}-{suffix}-{date}.tar.gz" @@ -99,10 +99,22 @@ def _export_volume(full_name: str) -> bytes: 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, @@ -159,14 +171,16 @@ async def create_backup( volumes = _compose_volumes(stack_id) if include_volumes else [] - # For a consistent volume snapshot, stop the stack first. + # 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. stopped = False if include_volumes and stop_first and volumes: - try: - await compose_service.stop(stack_id) - stopped = True - except Exception as exc: # noqa: BLE001 - logger.warning("Could not stop %s before backup: %s", stack_id, exc) + if compose_service.compute_status(stack_id) not in ("stopped", "unknown"): + try: + await compose_service.stop(stack_id) + stopped = True + except Exception as exc: # noqa: BLE001 + logger.warning("Could not stop %s before backup: %s", stack_id, exc) try: manifest = { @@ -247,9 +261,12 @@ def restore_backup( ) -> dict: """Restore a backup. Returns {stack_id, name, volumes_restored}.""" manifest = read_manifest(tar_path) - stack_id = target_id or manifest.get("stack_id") - if not stack_id: + raw_id = target_id or manifest.get("stack_id") or "" + if not raw_id.strip(): raise BackupError("Backup manifest has no stack id") + # 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) directory = compose_service.stack_dir(stack_id) exists = os.path.isdir(directory) diff --git a/backend/version.py b/backend/version.py index 426a640..aaf3896 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.32.0" +APP_VERSION = "0.32.1" diff --git a/frontend/package.json b/frontend/package.json index 02a5db7..bc54b94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.32.0", + "version": "0.32.1", "type": "module", "scripts": { "dev": "vite",