Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.

- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional
  backups, snapshots, HA, replication, Ceph capacity, and OSD totals
  (10 / 100 / 500) plus matching node disks and crush/pg metadata
- Enrich handler responses for apt, certificates, qemu/lxc status, storage,
  SDN, metrics export, and related cluster/node dumps
- Flatten nested body_example fields into PARAMS and sync the request body
  via dotted paths (oVirt-style)
- Restyle DATA controls as size cards with full-width Reset to minimal /
  Refresh stats; unload reloads the minimal cluster
This commit is contained in:
Sergey Antropoff
2026-07-18 08:46:11 +03:00
parent 48df10b17e
commit 0773f721ea
77 changed files with 4870 additions and 855 deletions
+182 -132
View File
@@ -1,8 +1,8 @@
"""Cluster backup and vzdump handlers."""
"""Cluster backup jobs and node vzdump handlers."""
from __future__ import annotations
import json
import secrets
from typing import Any
from fastapi import Request
@@ -10,149 +10,198 @@ 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.handlers.common import (
cluster_metadata,
database,
require_node,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _backup_jobs(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
jobs = metadata.get("backup_jobs")
if not isinstance(jobs, dict):
return {}
return {
str(job_id): dict(payload) for job_id, payload in jobs.items() if isinstance(payload, dict)
}
def _job_view(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
view = dict(payload)
view["id"] = job_id
enabled = view.get("enabled", 1)
view["enabled"] = bool(int(enabled)) if not isinstance(enabled, bool) else enabled
return view
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"""
async def backup_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
return [_job_view(job_id, payload) for job_id, payload in sorted(jobs.items())]
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
payload = jobs.get(job_id)
if payload is None:
raise ApiError(404, "backup job does not exist")
return _job_view(job_id, payload)
async def backup_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
job_id = str(payload.get("id") or f"backup-{secrets.token_hex(3)}")
if job_id in jobs:
raise ApiError(400, f"backup job '{job_id}' already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"delete", "digest", "node", "target"}
}
entry["id"] = job_id
entry.setdefault("enabled", 1)
entry.setdefault("storage", payload.get("storage") or "local")
jobs[job_id] = entry
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
payload = values(inputs)
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
current = jobs.get(job_id)
if current is None:
raise ApiError(404, "backup job does not exist")
updated = dict(current)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
updated[key] = value
updated["id"] = job_id
jobs[job_id] = updated
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
if job_id not in jobs:
raise ApiError(404, "backup job does not exist")
del jobs[job_id]
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("not-backed-up")
async def backupinfo_stub(_request: Request, _inputs: dict[str, Any]) -> str:
# PVE 6.x `/cluster/backupinfo` returns a stub string; 7+ uses `/backup-info` index.
return "Please use the 'not-backed-up' API"
async def backup_not_backed_up(
request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
covered: set[str] = set()
for payload in jobs.values():
if int(payload.get("all") or 0):
covered.add("*")
for item in str(payload.get("vmid") or "").split(","):
item = item.strip()
if item:
covered.add(item)
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.kind, r.state FROM resources r
WHERE r.kind IN ('qemu', 'lxc')
ORDER BY r.external_id::integer"""
)
result: list[dict[str, Any]] = []
if "*" in covered:
return result
for row in rows:
metadata = state(row["metadata"])
vmid = str(row["external_id"])
if vmid in covered:
continue
guest_state = state(row["state"])
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"),
"vmid": int(vmid),
"type": "qemu" if row["kind"] == "qemu" else "lxc",
"name": str(guest_state.get("name") or f"{row['kind']}-{vmid}"),
}
)
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
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
job = jobs.get(job_id)
if job is None:
raise ApiError(404, "backup job does not exist")
vmids = [item.strip() for item in str(job.get("vmid") or "").split(",") if item.strip()]
guest_select = """SELECT r.external_id, r.kind, r.state,
v.config AS qemu_config, c.config AS lxc_config
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"])]
LEFT JOIN virtual_machines v ON v.resource_id = r.id
LEFT JOIN containers c ON c.resource_id = r.id"""
if int(job.get("all") or 0):
rows = await database(request).pool.fetch(
f"""{guest_select}
WHERE r.kind IN ('qemu', 'lxc')
ORDER BY r.external_id::integer"""
)
else:
rows = await database(request).pool.fetch(
f"""{guest_select}
WHERE r.kind IN ('qemu', 'lxc') AND r.external_id = ANY($1::text[])
ORDER BY r.external_id::integer""",
vmids,
)
children: list[dict[str, Any]] = []
for row in rows:
guest_state = state(row["state"])
config_raw = row["qemu_config"] if row["kind"] == "qemu" else row["lxc_config"]
config = state(config_raw) if config_raw is not None else {}
volumes: list[dict[str, Any]] = []
for key, value in sorted(config.items()):
if not (
key.startswith("scsi")
or key.startswith("virtio")
or key.startswith("sata")
or key.startswith("ide")
or key in {"rootfs", "mp0", "mp1", "mp2", "mp3"}
):
continue
volumes.append(
{
"id": key,
"name": str(value).split(",", 1)[0],
"included": True,
"reason": "included by default",
}
)
children.append(
{
"id": int(row["external_id"]),
"name": str(guest_state.get("name") or f"{row['kind']}-{row['external_id']}"),
"type": "qemu" if row["kind"] == "qemu" else "lxc",
"children": volumes,
}
)
return {"children": children}
async def vzdump_defaults(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.nodes import load_node_ops
@@ -193,6 +242,7 @@ def register_backup_handlers(registry: HandlerRegistry) -> None:
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/backupinfo", "GET", backupinfo_stub)
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)
@@ -229,11 +279,11 @@ async def _schedule_vzdump(
payload={
"node": node,
"vmids": vmids,
"storage": str(payload.get("storage") or "nfs-backup"),
"storage": str(payload.get("storage") or "local"),
"mode": str(payload.get("mode") or "snapshot"),
"compress": str(payload.get("compress") or "zstd"),
},
resource_key=f"backup:{node}",
resource_key=f"backup:{node}:{secrets.token_hex(4)}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error: