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"
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _backup_filename(stack_id: str, include_volumes: bool) -> str:
|
_backup_filename = backup_service.backup_filename
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{stack_id}/backup")
|
@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:
|
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"
|
suffix = "full" if include_volumes else "config"
|
||||||
return f"{backup_basename(stack_id, prefix)}-{suffix}-{date}.tar.gz"
|
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:
|
def _restore_volume(full_name: str, labels: dict, tar_bytes: bytes) -> None:
|
||||||
client = get_client()
|
client = get_client()
|
||||||
_ensure_helper_image(client)
|
_ensure_helper_image(client)
|
||||||
|
existed = True
|
||||||
try:
|
try:
|
||||||
safe_call(client.volumes.get, full_name)
|
safe_call(client.volumes.get, full_name)
|
||||||
except DockerError:
|
except DockerError:
|
||||||
|
existed = False
|
||||||
safe_call(client.volumes.create, name=full_name, labels=labels or {})
|
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(
|
container = safe_call(
|
||||||
client.containers.create,
|
client.containers.create,
|
||||||
settings.BACKUP_HELPER_IMAGE,
|
settings.BACKUP_HELPER_IMAGE,
|
||||||
@@ -159,9 +171,11 @@ async def create_backup(
|
|||||||
|
|
||||||
volumes = _compose_volumes(stack_id) if include_volumes else []
|
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
|
stopped = False
|
||||||
if include_volumes and stop_first and volumes:
|
if include_volumes and stop_first and volumes:
|
||||||
|
if compose_service.compute_status(stack_id) not in ("stopped", "unknown"):
|
||||||
try:
|
try:
|
||||||
await compose_service.stop(stack_id)
|
await compose_service.stop(stack_id)
|
||||||
stopped = True
|
stopped = True
|
||||||
@@ -247,9 +261,12 @@ def restore_backup(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Restore a backup. Returns {stack_id, name, volumes_restored}."""
|
"""Restore a backup. Returns {stack_id, name, volumes_restored}."""
|
||||||
manifest = read_manifest(tar_path)
|
manifest = read_manifest(tar_path)
|
||||||
stack_id = target_id or manifest.get("stack_id")
|
raw_id = target_id or manifest.get("stack_id") or ""
|
||||||
if not stack_id:
|
if not raw_id.strip():
|
||||||
raise BackupError("Backup manifest has no stack id")
|
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)
|
directory = compose_service.stack_dir(stack_id)
|
||||||
exists = os.path.isdir(directory)
|
exists = os.path.isdir(directory)
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""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",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.32.0",
|
"version": "0.32.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user