From 7d94ec0d18b46893e23680cf8438109a34cc2a10 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 22 Feb 2026 16:55:03 +0100
Subject: [PATCH] feat: promote/sync/rebuild UI, operations page, bidirectional sync, lifecycle ops
---
app/routers/backups.py | 68 ++++++++++++++++++++++++++++++++--
1 files changed, 64 insertions(+), 4 deletions(-)
diff --git a/app/routers/backups.py b/app/routers/backups.py
index d1cf26e..de5a15c 100644
--- a/app/routers/backups.py
+++ b/app/routers/backups.py
@@ -1,9 +1,9 @@
from typing import Any
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth import verify_token
-from app.ops_runner import run_ops, run_ops_json, run_ops_host, _BACKUP_TIMEOUT
+from app.ops_runner import run_ops, run_ops_json, run_ops_host, run_ops_host_json, run_command_host, _BACKUP_TIMEOUT
router = APIRouter()
@@ -33,9 +33,22 @@
_: str = Depends(verify_token),
) -> list[dict[str, Any]]:
"""Returns a list of offsite backup records."""
+ # Get project list from registry
+ import yaml
+ registry_path = "/opt/infrastructure/servers/hetzner-vps/registry.yaml"
+ try:
+ with open(registry_path) as f:
+ registry = yaml.safe_load(f)
+ projects = [
+ name for name, cfg in registry.get("projects", {}).items()
+ if cfg.get("backup_dir") and not cfg.get("infrastructure") and not cfg.get("static")
+ ]
+ except Exception:
+ projects = ["mdf", "seriousletter"] # Fallback
+
all_backups = []
- for project in ["mdf", "seriousletter"]:
- result = await run_ops_json(["offsite", "list", project])
+ for project in projects:
+ result = await run_ops_host_json(["offsite", "list", project])
if result["success"] and isinstance(result["data"], list):
for b in result["data"]:
b["project"] = project
@@ -99,3 +112,50 @@
detail=f"Retention policy failed: {result['error'] or result['output']}",
)
return {"success": True, "output": result["output"]}
+
+
+@router.delete("/{project}/{env}/{name}", summary="Delete a backup")
+async def delete_backup(
+ project: str,
+ env: str,
+ name: str,
+ target: str = Query("local", regex="^(local|offsite|both)$"),
+ _: str = Depends(verify_token),
+) -> dict[str, Any]:
+ """
+ Delete a backup from local storage, offsite, or both.
+
+ Query param `target`: local | offsite | both (default: local).
+ """
+ if "/" in name or "\\" in name or ".." in name:
+ raise HTTPException(status_code=400, detail="Invalid backup name")
+
+ results = {"local": None, "offsite": None}
+
+ # Delete local
+ if target in ("local", "both"):
+ backup_path = f"/opt/data/backups/{project}/{env}/{name}"
+ check = await run_command_host(["test", "-f", backup_path])
+ if check["success"]:
+ result = await run_command_host(["rm", backup_path])
+ results["local"] = "ok" if result["success"] else "failed"
+ else:
+ results["local"] = "not_found"
+
+ # Delete offsite
+ if target in ("offsite", "both"):
+ result = await run_command_host([
+ "/opt/data/\u03c0/bin/python3", "-c",
+ f"import sys; sys.path.insert(0, '/opt/data/scripts'); "
+ f"from offsite import delete; "
+ f"ok = delete('{name}', '{project}', '{env}', quiet=True); "
+ f"sys.exit(0 if ok else 1)"
+ ])
+ results["offsite"] = "ok" if result["success"] else "failed"
+
+ # Check if anything succeeded
+ any_ok = "ok" in results.values()
+ if not any_ok:
+ raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
+
+ return {"success": True, "project": project, "env": env, "name": name, "results": results}
--
Gitblit v1.3.1