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",