| .. | .. |
|---|
| 1 | 1 | from typing import Any |
|---|
| 2 | 2 | |
|---|
| 3 | | -from fastapi import APIRouter, Depends, HTTPException |
|---|
| 3 | +from fastapi import APIRouter, Depends, HTTPException, Query |
|---|
| 4 | 4 | |
|---|
| 5 | 5 | 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 |
|---|
| 7 | 7 | |
|---|
| 8 | 8 | router = APIRouter() |
|---|
| 9 | 9 | |
|---|
| .. | .. |
|---|
| 33 | 33 | _: str = Depends(verify_token), |
|---|
| 34 | 34 | ) -> list[dict[str, Any]]: |
|---|
| 35 | 35 | """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 | + |
|---|
| 36 | 49 | 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]) |
|---|
| 39 | 52 | if result["success"] and isinstance(result["data"], list): |
|---|
| 40 | 53 | for b in result["data"]: |
|---|
| 41 | 54 | b["project"] = project |
|---|
| .. | .. |
|---|
| 99 | 112 | detail=f"Retention policy failed: {result['error'] or result['output']}", |
|---|
| 100 | 113 | ) |
|---|
| 101 | 114 | 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} |
|---|