777926487b
initial QEMU slice, backed by imported contracts for majors 6–9. - Implement durable handlers for access/auth, cluster, LXC, storage, HA, firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops - Serve an interactive Web UI with catalog browsing, demo seed controls, and OpenAPI/help surfaces - Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3 - Support in-memory runtime contract Apply (POST /ui/api/contract/apply) so /version and /api2 routes follow the selected major until restart - Expand seed profiles (including demo-cluster), migrations 007–008, TLS gateway config, Compose/Makefile tooling, and compatibility evidence - Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
237 lines
9.6 KiB
Python
237 lines
9.6 KiB
Python
"""Cluster backup and vzdump handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi import Request
|
|
|
|
from app.api.errors import ApiError
|
|
from app.api.registry import HandlerRegistry
|
|
from app.db.primitives import ConflictError
|
|
from app.handlers.common import database, require_node, state, values
|
|
from app.tasks.repository import TaskRepository
|
|
from app.tasks.upid import Upid
|
|
|
|
|
|
def register_backup_handlers(registry: HandlerRegistry) -> None:
|
|
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
rows = await database(_request).pool.fetch(
|
|
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
|
|
r.external_id AS vmid, n.name AS node, s.storage_id
|
|
FROM backups b
|
|
LEFT JOIN resources r ON r.id = b.resource_id
|
|
LEFT JOIN nodes n ON n.id = r.node_id
|
|
JOIN storages s ON s.resource_id = b.storage_resource_id
|
|
ORDER BY b.created_at DESC LIMIT 2000"""
|
|
)
|
|
result: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
metadata = state(row["metadata"])
|
|
result.append(
|
|
{
|
|
"id": str(row["id"]),
|
|
"volid": str(row["volume_id"]),
|
|
"size": int(row["size_bytes"]),
|
|
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
|
|
"node": str(row["node"]) if row["node"] is not None else None,
|
|
"storage": str(row["storage_id"]),
|
|
"starttime": int(row["created_at"].timestamp()),
|
|
"mode": metadata.get("mode", "snapshot"),
|
|
"type": metadata.get("type", "vzdump"),
|
|
}
|
|
)
|
|
return result
|
|
|
|
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
backup_id = str(values(inputs)["id"])
|
|
row = await database(request).pool.fetchrow(
|
|
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
|
|
r.external_id AS vmid, n.name AS node, s.storage_id
|
|
FROM backups b
|
|
LEFT JOIN resources r ON r.id = b.resource_id
|
|
LEFT JOIN nodes n ON n.id = r.node_id
|
|
JOIN storages s ON s.resource_id = b.storage_resource_id
|
|
WHERE b.id::text = $1 OR b.volume_id = $1""",
|
|
backup_id,
|
|
)
|
|
if row is None:
|
|
raise ApiError(404, "backup does not exist")
|
|
metadata = state(row["metadata"])
|
|
return {
|
|
"id": str(row["id"]),
|
|
"volid": str(row["volume_id"]),
|
|
"size": int(row["size_bytes"]),
|
|
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
|
|
"node": str(row["node"]) if row["node"] is not None else None,
|
|
"storage": str(row["storage_id"]),
|
|
"starttime": int(row["created_at"].timestamp()),
|
|
"notes": metadata.get("notes-template"),
|
|
**metadata,
|
|
}
|
|
|
|
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
|
|
backup_id = str(values(inputs)["id"])
|
|
row = await database(request).pool.fetchrow(
|
|
"SELECT id, metadata FROM backups WHERE id::text = $1",
|
|
backup_id,
|
|
)
|
|
if row is None:
|
|
raise ApiError(404, "backup does not exist")
|
|
metadata = state(row["metadata"])
|
|
payload = values(inputs)
|
|
if "notes" in payload:
|
|
metadata["notes-template"] = payload["notes"]
|
|
await database(request).pool.execute(
|
|
"UPDATE backups SET metadata=$2::jsonb WHERE id=$1",
|
|
row["id"],
|
|
json.dumps(metadata, sort_keys=True),
|
|
)
|
|
|
|
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
|
|
backup_id = str(values(inputs)["id"])
|
|
status = await database(request).pool.execute(
|
|
"DELETE FROM backups WHERE id::text = $1",
|
|
backup_id,
|
|
)
|
|
if status != "DELETE 1":
|
|
raise ApiError(404, "backup does not exist")
|
|
|
|
async def backup_create(request: Request, inputs: dict[str, Any]) -> str:
|
|
payload = values(inputs)
|
|
node = str(payload.get("node") or payload.get("target") or "pve01")
|
|
await require_node(request, node)
|
|
vmid = payload.get("vmid")
|
|
return await _schedule_vzdump(
|
|
request,
|
|
node=node,
|
|
vmids=[str(vmid)] if vmid is not None else None,
|
|
payload=payload,
|
|
)
|
|
|
|
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
rows = await database(_request).pool.fetch(
|
|
"""SELECT r.external_id AS vmid, n.name AS node, max(b.created_at) AS last_backup
|
|
FROM resources r
|
|
JOIN nodes n ON n.id = r.node_id
|
|
LEFT JOIN backups b ON b.resource_id = r.id
|
|
WHERE r.kind = 'qemu'
|
|
GROUP BY r.external_id, n.name
|
|
ORDER BY r.external_id::integer
|
|
LIMIT 5000"""
|
|
)
|
|
return [
|
|
{
|
|
"vmid": int(row["vmid"]),
|
|
"node": str(row["node"]),
|
|
"lastbackup": int(row["last_backup"].timestamp()) if row["last_backup"] else 0,
|
|
"protected": 0,
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
async def backup_not_backed_up(_request: Request, _inputs: dict[str, Any]) -> list[int]:
|
|
rows = await database(_request).pool.fetch(
|
|
"""SELECT r.external_id::integer AS vmid
|
|
FROM resources r
|
|
LEFT JOIN backups b ON b.resource_id = r.id
|
|
WHERE r.kind = 'qemu' AND b.id IS NULL
|
|
ORDER BY r.external_id::integer"""
|
|
)
|
|
return [int(row["vmid"]) for row in rows]
|
|
|
|
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]:
|
|
backup_id = str(values(inputs)["id"])
|
|
row = await database(request).pool.fetchrow(
|
|
"""SELECT b.volume_id, r.external_id AS vmid
|
|
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
|
|
WHERE b.id::text = $1""",
|
|
backup_id,
|
|
)
|
|
if row is None:
|
|
raise ApiError(404, "backup does not exist")
|
|
vmid = row["vmid"]
|
|
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
|
|
|
|
async def vzdump_defaults(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
await require_node(_request, str(values(inputs)["node"]))
|
|
return {
|
|
"all": 0,
|
|
"bwlimit": 0,
|
|
"compress": "zstd",
|
|
"dumpdir": "backup",
|
|
"mode": "snapshot",
|
|
"remove": 0,
|
|
"storage": "nfs-backup",
|
|
"mailto": "",
|
|
"notes-template": "{{guestname}}",
|
|
}
|
|
|
|
async def vzdump_extractconfig(request: Request, inputs: dict[str, Any]) -> str:
|
|
payload = values(inputs)
|
|
volid = str(payload.get("volume") or payload.get("volid") or "")
|
|
if not volid:
|
|
raise ApiError(400, "volume parameter required")
|
|
return f"# simulated vzdump config extracted from {volid}\name: demo\nmemory: 2048\n"
|
|
|
|
async def vzdump_create(request: Request, inputs: dict[str, Any]) -> str:
|
|
payload = values(inputs)
|
|
node = str(payload["node"])
|
|
await require_node(request, node)
|
|
vmids = payload.get("vmid")
|
|
selected = None
|
|
if vmids is not None:
|
|
selected = [str(item) for item in str(vmids).split(",") if item.strip()]
|
|
return await _schedule_vzdump(request, node=node, vmids=selected, payload=payload)
|
|
|
|
registry.register("/cluster/backup", "GET", backup_list)
|
|
registry.register("/cluster/backup", "POST", backup_create)
|
|
registry.register("/cluster/backup-info", "GET", backup_info)
|
|
registry.register("/cluster/backup-info/not-backed-up", "GET", backup_not_backed_up)
|
|
registry.register("/cluster/backup/{id}", "GET", backup_get)
|
|
registry.register("/cluster/backup/{id}", "PUT", backup_update)
|
|
registry.register("/cluster/backup/{id}", "DELETE", backup_delete)
|
|
registry.register("/cluster/backup/{id}/included_volumes", "GET", backup_included_volumes)
|
|
registry.register("/nodes/{node}/vzdump", "POST", vzdump_create)
|
|
registry.register("/nodes/{node}/vzdump/defaults", "GET", vzdump_defaults)
|
|
registry.register("/nodes/{node}/vzdump/extractconfig", "GET", vzdump_extractconfig)
|
|
|
|
|
|
async def _schedule_vzdump(
|
|
request: Request,
|
|
*,
|
|
node: str,
|
|
vmids: list[str] | None,
|
|
payload: dict[str, Any],
|
|
) -> str:
|
|
pool = database(request).pool
|
|
if vmids is None:
|
|
rows = await pool.fetch(
|
|
"""SELECT external_id FROM resources r JOIN nodes n ON n.id=r.node_id
|
|
WHERE n.name=$1 AND r.kind='qemu' ORDER BY external_id::integer LIMIT 100""",
|
|
node,
|
|
)
|
|
vmids = [str(row["external_id"]) for row in rows]
|
|
if not vmids:
|
|
raise ApiError(400, "no virtual machines selected for backup")
|
|
vmid = vmids[0]
|
|
upid = str(Upid.allocate(node, "vzdump", vmid, str(request.state.principal)))
|
|
try:
|
|
task = await TaskRepository(pool).create(
|
|
upid=upid,
|
|
task_type="vzdump",
|
|
payload={
|
|
"node": node,
|
|
"vmids": vmids,
|
|
"storage": str(payload.get("storage") or "nfs-backup"),
|
|
"mode": str(payload.get("mode") or "snapshot"),
|
|
"compress": str(payload.get("compress") or "zstd"),
|
|
},
|
|
resource_key=f"backup:{node}",
|
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
|
)
|
|
except ConflictError as error:
|
|
raise ApiError(409, str(error)) from error
|
|
return task.upid
|