Matthias Nott
2026-02-22 7d94ec0d18b46893e23680cf8438109a34cc2a10
app/routers/backups.py
....@@ -1,9 +1,9 @@
11 from typing import Any
22
3
-from fastapi import APIRouter, Depends, HTTPException
3
+from fastapi import APIRouter, Depends, HTTPException, Query
44
55 from app.auth import verify_token
6
-from app.ops_runner import run_ops, run_ops_json, run_ops_host, _BACKUP_TIMEOUT
6
+from app.ops_runner import run_ops, run_ops_json, run_ops_host, run_ops_host_json, run_command_host, _BACKUP_TIMEOUT
77
88 router = APIRouter()
99
....@@ -33,9 +33,22 @@
3333 _: str = Depends(verify_token),
3434 ) -> list[dict[str, Any]]:
3535 """Returns a list of offsite backup records."""
36
+ # Get project list from registry
37
+ import yaml
38
+ registry_path = "/opt/infrastructure/servers/hetzner-vps/registry.yaml"
39
+ try:
40
+ with open(registry_path) as f:
41
+ registry = yaml.safe_load(f)
42
+ projects = [
43
+ name for name, cfg in registry.get("projects", {}).items()
44
+ if cfg.get("backup_dir") and not cfg.get("infrastructure") and not cfg.get("static")
45
+ ]
46
+ except Exception:
47
+ projects = ["mdf", "seriousletter"] # Fallback
48
+
3649 all_backups = []
37
- for project in ["mdf", "seriousletter"]:
38
- result = await run_ops_json(["offsite", "list", project])
50
+ for project in projects:
51
+ result = await run_ops_host_json(["offsite", "list", project])
3952 if result["success"] and isinstance(result["data"], list):
4053 for b in result["data"]:
4154 b["project"] = project
....@@ -99,3 +112,50 @@
99112 detail=f"Retention policy failed: {result['error'] or result['output']}",
100113 )
101114 return {"success": True, "output": result["output"]}
115
+
116
+
117
+@router.delete("/{project}/{env}/{name}", summary="Delete a backup")
118
+async def delete_backup(
119
+ project: str,
120
+ env: str,
121
+ name: str,
122
+ target: str = Query("local", regex="^(local|offsite|both)$"),
123
+ _: str = Depends(verify_token),
124
+) -> dict[str, Any]:
125
+ """
126
+ Delete a backup from local storage, offsite, or both.
127
+
128
+ Query param `target`: local | offsite | both (default: local).
129
+ """
130
+ if "/" in name or "\\" in name or ".." in name:
131
+ raise HTTPException(status_code=400, detail="Invalid backup name")
132
+
133
+ results = {"local": None, "offsite": None}
134
+
135
+ # Delete local
136
+ if target in ("local", "both"):
137
+ backup_path = f"/opt/data/backups/{project}/{env}/{name}"
138
+ check = await run_command_host(["test", "-f", backup_path])
139
+ if check["success"]:
140
+ result = await run_command_host(["rm", backup_path])
141
+ results["local"] = "ok" if result["success"] else "failed"
142
+ else:
143
+ results["local"] = "not_found"
144
+
145
+ # Delete offsite
146
+ if target in ("offsite", "both"):
147
+ result = await run_command_host([
148
+ "/opt/data/\u03c0/bin/python3", "-c",
149
+ f"import sys; sys.path.insert(0, '/opt/data/scripts'); "
150
+ f"from offsite import delete; "
151
+ f"ok = delete('{name}', '{project}', '{env}', quiet=True); "
152
+ f"sys.exit(0 if ok else 1)"
153
+ ])
154
+ results["offsite"] = "ok" if result["success"] else "failed"
155
+
156
+ # Check if anything succeeded
157
+ any_ok = "ok" in results.values()
158
+ if not any_ok:
159
+ raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
160
+
161
+ return {"success": True, "project": project, "env": env, "name": name, "results": results}