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:
+182
-132
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user