0.32.1: backup/restore fixes (audit findings, all paths live-verified)

- 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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 12:10:45 +00:00
co-authored by Claude Fable 5
parent a0dda120f5
commit 79d82361d8
4 changed files with 29 additions and 15 deletions
+1 -4
View File
@@ -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")
+26 -9
View File
@@ -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)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.32.0"
APP_VERSION = "0.32.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.32.0",
"version": "0.32.1",
"type": "module",
"scripts": {
"dev": "vite",