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:
co-authored by
Claude Fable 5
parent
a0dda120f5
commit
79d82361d8
@@ -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")
|
||||
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.32.0"
|
||||
APP_VERSION = "0.32.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user