1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
| | from typing import Any
| |
| | 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, run_ops_host_json, run_command_host, _BACKUP_TIMEOUT
| |
| | router = APIRouter()
| |
| |
| | @router.get("/", summary="List local backups")
| | async def list_backups(
| | _: str = Depends(verify_token),
| | ) -> list[dict[str, Any]]:
| | """Returns a list of local backup records from `ops backups --json`."""
| | result = await run_ops_json(["backups"])
| | if not result["success"]:
| | raise HTTPException(status_code=500, detail=f"Failed to list backups: {result['error']}")
| |
| | data = result["data"]
| | if isinstance(data, list):
| | return data
| | if isinstance(data, dict):
| | for key in ("backups", "data", "items"):
| | if key in data and isinstance(data[key], list):
| | return data[key]
| | return [data]
| | return []
| |
| |
| | @router.get("/offsite", summary="List offsite backups")
| | async def list_offsite_backups(
| | _: 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 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
| | all_backups.extend(result["data"])
| | return all_backups
| |
| |
| | @router.post("/{project}/{env}", summary="Create a local backup")
| | async def create_backup(
| | project: str,
| | env: str,
| | _: str = Depends(verify_token),
| | ) -> dict[str, Any]:
| | """
| | Runs `ops backup {project} {env}` on the host.
| |
| | Runs via nsenter because ops backup delegates to project CLIs
| | that use host Python venvs.
| | """
| | result = await run_ops_host(["backup", project, env], timeout=_BACKUP_TIMEOUT)
| | if not result["success"]:
| | raise HTTPException(
| | status_code=500,
| | detail=f"Backup failed: {result['error'] or result['output']}",
| | )
| | return {
| | "success": True,
| | "output": result["output"],
| | "project": project,
| | "env": env,
| | }
| |
| |
| | @router.post("/offsite/upload/{project}/{env}", summary="Upload backup to offsite")
| | async def upload_offsite(
| | project: str,
| | env: str,
| | _: str = Depends(verify_token),
| | ) -> dict[str, Any]:
| | """Runs `ops offsite upload {project} {env}` on the host."""
| | result = await run_ops_host(
| | ["offsite", "upload", project, env], timeout=_BACKUP_TIMEOUT
| | )
| | if not result["success"]:
| | raise HTTPException(
| | status_code=500,
| | detail=f"Offsite upload failed: {result['error'] or result['output']}",
| | )
| | return {"success": True, "output": result["output"], "project": project, "env": env}
| |
| |
| | @router.post("/offsite/retention", summary="Apply offsite retention policy")
| | async def apply_retention(
| | _: str = Depends(verify_token),
| | ) -> dict[str, Any]:
| | """Runs `ops offsite retention` on the host."""
| | result = await run_ops_host(["offsite", "retention"], timeout=_BACKUP_TIMEOUT)
| | if not result["success"]:
| | raise HTTPException(
| | status_code=500,
| | 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}
|
|