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