Add a stateful Proxmox API console and broad handler coverage beyond the
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
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
"""Node-level operational handlers (apt, network, disks, services)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.common import (
|
||||
database,
|
||||
node_metadata,
|
||||
require_node,
|
||||
save_node_metadata,
|
||||
subdirs,
|
||||
values,
|
||||
)
|
||||
from app.tasks.repository import TaskRepository
|
||||
from app.tasks.upid import Upid
|
||||
|
||||
DEFAULT_NODE_OPS: dict[str, Any] = {
|
||||
"network": [
|
||||
{
|
||||
"iface": "vmbr0",
|
||||
"type": "bridge",
|
||||
"active": 1,
|
||||
"method": "static",
|
||||
"address": "10.0.0.10/24",
|
||||
},
|
||||
{
|
||||
"iface": "vmbr1",
|
||||
"type": "bridge",
|
||||
"active": 1,
|
||||
"method": "static",
|
||||
"address": "10.10.0.10/24",
|
||||
},
|
||||
{"iface": "eno1", "type": "eth", "active": 1, "method": "manual"},
|
||||
],
|
||||
"disks": {
|
||||
"list": [
|
||||
{
|
||||
"devpath": "/dev/sda",
|
||||
"size": 1_000_000_000_000,
|
||||
"model": "SIM-DISK-01",
|
||||
"serial": "SIM0001",
|
||||
"gpt": 1,
|
||||
},
|
||||
{
|
||||
"devpath": "/dev/sdb",
|
||||
"size": 2_000_000_000_000,
|
||||
"model": "SIM-SSD-01",
|
||||
"serial": "SIM0002",
|
||||
"gpt": 0,
|
||||
},
|
||||
],
|
||||
"directory": [],
|
||||
"lvm": [],
|
||||
"lvmthin": [],
|
||||
"zfs": [],
|
||||
"smart": {},
|
||||
},
|
||||
"services": {
|
||||
"pveproxy": {"state": "running", "enabled": 1},
|
||||
"pvedaemon": {"state": "running", "enabled": 1},
|
||||
"pvestatd": {"state": "running", "enabled": 1},
|
||||
"corosync": {"state": "running", "enabled": 1},
|
||||
},
|
||||
"apt": {
|
||||
"packages": [
|
||||
{
|
||||
"Package": "pve-manager",
|
||||
"Version": "9.2.3",
|
||||
"OldVersion": "9.2.2",
|
||||
"Status": "upgradable",
|
||||
},
|
||||
{"Package": "libpve-common-perl", "Version": "9.0.3", "Status": "installed"},
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
|
||||
"enabled": 1,
|
||||
"types": "deb",
|
||||
"uri": "http://download.proxmox.com/debian/pve",
|
||||
"suites": "bookworm",
|
||||
"components": "pve-no-subscription",
|
||||
}
|
||||
],
|
||||
"update": {"status": "stopped", "exitstatus": "OK"},
|
||||
"changelogs": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def default_node_ops() -> dict[str, Any]:
|
||||
return copy.deepcopy(DEFAULT_NODE_OPS)
|
||||
|
||||
|
||||
async def load_node_ops(request: Request, node: str) -> dict[str, Any]:
|
||||
metadata = await node_metadata(request, node)
|
||||
ops = metadata.get("ops")
|
||||
if isinstance(ops, dict) and ops:
|
||||
return ops
|
||||
ops = default_node_ops()
|
||||
metadata["ops"] = ops
|
||||
await save_node_metadata(request, node, metadata)
|
||||
return ops
|
||||
|
||||
|
||||
async def save_node_ops(request: Request, node: str, ops: dict[str, Any]) -> None:
|
||||
metadata = await node_metadata(request, node)
|
||||
metadata["ops"] = ops
|
||||
await save_node_metadata(request, node, metadata)
|
||||
|
||||
|
||||
def register_node_ops_handlers(registry: HandlerRegistry) -> None:
|
||||
async def apt_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("changelog", "repositories", "update", "versions")
|
||||
|
||||
async def apt_versions(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
packages = ops.get("apt", {}).get("packages", [])
|
||||
return list(packages) if isinstance(packages, list) else []
|
||||
|
||||
async def apt_repositories(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
repositories = ops.get("apt", {}).get("repositories", [])
|
||||
return list(repositories) if isinstance(repositories, list) else []
|
||||
|
||||
async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
name = str(values(inputs).get("name") or "pve-manager")
|
||||
ops = await load_node_ops(request, node)
|
||||
changelogs = ops.setdefault("apt", {}).setdefault("changelogs", {})
|
||||
if name not in changelogs:
|
||||
changelogs[name] = f"simulated changelog for {name}\n\n * emulator build\n"
|
||||
await save_node_ops(request, node, ops)
|
||||
return str(changelogs[name])
|
||||
|
||||
async def apt_update_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
update = ops.get("apt", {}).get("update", {"status": "stopped", "exitstatus": "OK"})
|
||||
if isinstance(update, dict):
|
||||
return dict(update)
|
||||
return {"status": "stopped", "exitstatus": "OK"}
|
||||
|
||||
async def apt_update_start(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
apt = ops.setdefault("apt", {})
|
||||
apt["update"] = {"status": "running", "exitstatus": ""}
|
||||
await save_node_ops(request, node, ops)
|
||||
return await _node_task(request, node=node, task_type="aptupdate", worker="aptupdate")
|
||||
|
||||
async def network_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
network = ops.get("network", [])
|
||||
return [dict(item) for item in network] if isinstance(network, list) else []
|
||||
|
||||
async def network_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
iface = str(values(inputs)["iface"])
|
||||
for item in await network_list(request, inputs):
|
||||
if item.get("iface") == iface:
|
||||
return item
|
||||
raise ApiError(404, "interface does not exist")
|
||||
|
||||
async def network_mutate(request: Request, inputs: dict[str, Any]) -> None:
|
||||
node = str(values(inputs)["node"])
|
||||
payload = values(inputs)
|
||||
ops = await load_node_ops(request, node)
|
||||
network = list(ops.get("network") or [])
|
||||
iface = payload.get("iface")
|
||||
method = request.method.upper()
|
||||
if method == "DELETE":
|
||||
target = str(iface or "")
|
||||
if not any(item.get("iface") == target for item in network):
|
||||
raise ApiError(404, "interface does not exist")
|
||||
ops["network"] = [item for item in network if item.get("iface") != target]
|
||||
elif method == "POST":
|
||||
name = str(iface or payload.get("iface") or "")
|
||||
if not name:
|
||||
raise ApiError(400, "parameter verification failed - 'iface' missing")
|
||||
if any(item.get("iface") == name for item in network):
|
||||
raise ApiError(400, f"interface '{name}' already exists")
|
||||
entry = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "delete", "digest"}
|
||||
}
|
||||
entry["iface"] = name
|
||||
entry.setdefault("type", "bridge")
|
||||
entry.setdefault("active", 1)
|
||||
network.append(entry)
|
||||
ops["network"] = network
|
||||
elif method == "PUT" and iface is not None:
|
||||
name = str(iface)
|
||||
found = False
|
||||
updated: list[dict[str, Any]] = []
|
||||
for item in network:
|
||||
if item.get("iface") != name:
|
||||
updated.append(item)
|
||||
continue
|
||||
found = True
|
||||
merged = {
|
||||
**item,
|
||||
**{
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "iface", "delete", "digest"}
|
||||
},
|
||||
}
|
||||
merged["iface"] = name
|
||||
updated.append(merged)
|
||||
if not found:
|
||||
raise ApiError(404, "interface does not exist")
|
||||
ops["network"] = updated
|
||||
else:
|
||||
ops["network_applied"] = True
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("directory", "list", "lvm", "lvmthin", "smart", "zfs")
|
||||
|
||||
async def _disks(request: Request, node: str) -> dict[str, Any]:
|
||||
ops = await load_node_ops(request, node)
|
||||
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||
if not isinstance(disks, dict):
|
||||
disks = default_node_ops()["disks"]
|
||||
ops["disks"] = disks
|
||||
await save_node_ops(request, node, ops)
|
||||
return cast(dict[str, Any], disks)
|
||||
|
||||
async def disks_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
disks = await _disks(request, node)
|
||||
items = disks.get("list", [])
|
||||
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||
|
||||
async def disks_smart(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
disk = str(values(inputs).get("disk") or "/dev/sda")
|
||||
disks = await _disks(request, node)
|
||||
smart = disks.setdefault("smart", {})
|
||||
if disk not in smart:
|
||||
smart[disk] = {
|
||||
"health": "PASSED",
|
||||
"type": "scsi",
|
||||
"model": "SIM-DISK",
|
||||
"serial": disk.rsplit("/", 1)[-1],
|
||||
}
|
||||
ops = await load_node_ops(request, node)
|
||||
ops["disks"] = disks
|
||||
await save_node_ops(request, node, ops)
|
||||
return dict(smart[disk])
|
||||
|
||||
async def disks_collection(
|
||||
request: Request, inputs: dict[str, Any], key: str
|
||||
) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
disks = await _disks(request, node)
|
||||
items = disks.get(key, [])
|
||||
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||
|
||||
async def disks_directory(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return await disks_collection(request, inputs, "directory")
|
||||
|
||||
async def disks_lvm(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return await disks_collection(request, inputs, "lvm")
|
||||
|
||||
async def disks_lvmthin(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return await disks_collection(request, inputs, "lvmthin")
|
||||
|
||||
async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return await disks_collection(request, inputs, "zfs")
|
||||
|
||||
async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> None:
|
||||
node = str(values(inputs)["node"])
|
||||
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
|
||||
if not disk:
|
||||
raise ApiError(400, "parameter verification failed - 'disk' missing")
|
||||
ops = await load_node_ops(request, node)
|
||||
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||
items = list(disks.get("list") or [])
|
||||
found = False
|
||||
for item in items:
|
||||
if item.get("devpath") == disk:
|
||||
item["gpt"] = 1
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
items.append(
|
||||
{
|
||||
"devpath": disk,
|
||||
"size": 0,
|
||||
"model": "SIM-DISK",
|
||||
"serial": disk,
|
||||
"gpt": 1,
|
||||
}
|
||||
)
|
||||
disks["list"] = items
|
||||
ops["disks"] = disks
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> None:
|
||||
node = str(values(inputs)["node"])
|
||||
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
|
||||
if not disk:
|
||||
raise ApiError(400, "parameter verification failed - 'disk' missing")
|
||||
ops = await load_node_ops(request, node)
|
||||
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||
items = list(disks.get("list") or [])
|
||||
for item in items:
|
||||
if item.get("devpath") == disk:
|
||||
item["wiped"] = 1
|
||||
item["gpt"] = 0
|
||||
break
|
||||
else:
|
||||
raise ApiError(404, "disk does not exist")
|
||||
disks["list"] = items
|
||||
smart = disks.setdefault("smart", {})
|
||||
smart.pop(disk, None)
|
||||
ops["disks"] = disks
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
node = str(values(inputs)["node"])
|
||||
ops = await load_node_ops(request, node)
|
||||
services = ops.get("services") or {}
|
||||
return [{"subdir": name} for name in sorted(services)]
|
||||
|
||||
async def service_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
service = str(values(inputs)["service"])
|
||||
ops = await load_node_ops(request, node)
|
||||
services = ops.setdefault("services", {})
|
||||
if service not in services:
|
||||
services[service] = {"state": "stopped", "enabled": 0}
|
||||
await save_node_ops(request, node, ops)
|
||||
payload = dict(services[service])
|
||||
payload["service"] = service
|
||||
return payload
|
||||
|
||||
async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return await service_get(request, inputs)
|
||||
|
||||
async def service_action(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
service = str(values(inputs)["service"])
|
||||
path = request.url.path.rstrip("/")
|
||||
action = path.rsplit("/", 1)[-1]
|
||||
ops = await load_node_ops(request, node)
|
||||
services = ops.setdefault("services", {})
|
||||
current = dict(services.get(service) or {"state": "stopped", "enabled": 0})
|
||||
if action == "start":
|
||||
current["state"] = "running"
|
||||
current["enabled"] = 1
|
||||
elif action == "stop":
|
||||
current["state"] = "stopped"
|
||||
elif action in {"restart", "reload"}:
|
||||
current["state"] = "running"
|
||||
current["enabled"] = 1
|
||||
else:
|
||||
raise ApiError(400, f"unknown service action: {action}")
|
||||
services[service] = current
|
||||
ops["services"] = services
|
||||
await save_node_ops(request, node, ops)
|
||||
return "OK"
|
||||
|
||||
registry.register("/nodes/{node}/apt", "GET", apt_index)
|
||||
registry.register("/nodes/{node}/apt/versions", "GET", apt_versions)
|
||||
registry.register("/nodes/{node}/apt/repositories", "GET", apt_repositories)
|
||||
registry.register("/nodes/{node}/apt/changelog", "GET", apt_changelog)
|
||||
registry.register("/nodes/{node}/apt/update", "GET", apt_update_status)
|
||||
registry.register("/nodes/{node}/apt/update", "POST", apt_update_start)
|
||||
registry.register("/nodes/{node}/network", "GET", network_list)
|
||||
registry.register("/nodes/{node}/network", "POST", network_mutate)
|
||||
registry.register("/nodes/{node}/network", "PUT", network_mutate)
|
||||
registry.register("/nodes/{node}/network/{iface}", "GET", network_get)
|
||||
registry.register("/nodes/{node}/network/{iface}", "PUT", network_mutate)
|
||||
registry.register("/nodes/{node}/network/{iface}", "DELETE", network_mutate)
|
||||
registry.register("/nodes/{node}/disks", "GET", disks_index)
|
||||
registry.register("/nodes/{node}/disks/list", "GET", disks_list)
|
||||
registry.register("/nodes/{node}/disks/smart", "GET", disks_smart)
|
||||
registry.register("/nodes/{node}/disks/directory", "GET", disks_directory)
|
||||
registry.register("/nodes/{node}/disks/lvm", "GET", disks_lvm)
|
||||
registry.register("/nodes/{node}/disks/lvmthin", "GET", disks_lvmthin)
|
||||
registry.register("/nodes/{node}/disks/zfs", "GET", disks_zfs)
|
||||
registry.register("/nodes/{node}/disks/initgpt", "POST", disks_initgpt)
|
||||
registry.register("/nodes/{node}/disks/wipedisk", "PUT", disks_wipedisk)
|
||||
registry.register("/nodes/{node}/services", "GET", services_index)
|
||||
registry.register("/nodes/{node}/services/{service}", "GET", service_get)
|
||||
registry.register("/nodes/{node}/services/{service}/state", "GET", service_state)
|
||||
registry.register("/nodes/{node}/services/{service}/start", "POST", service_action)
|
||||
registry.register("/nodes/{node}/services/{service}/stop", "POST", service_action)
|
||||
registry.register("/nodes/{node}/services/{service}/restart", "POST", service_action)
|
||||
registry.register("/nodes/{node}/services/{service}/reload", "POST", service_action)
|
||||
|
||||
|
||||
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
|
||||
from app.api.errors import ApiError
|
||||
from app.db.primitives import ConflictError
|
||||
|
||||
pool = database(request).pool
|
||||
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
|
||||
try:
|
||||
task = await TaskRepository(pool).create(
|
||||
upid=upid,
|
||||
task_type=task_type,
|
||||
payload={"node": node},
|
||||
resource_key=f"node:{node}",
|
||||
)
|
||||
except ConflictError as error:
|
||||
raise ApiError(409, str(error)) from error
|
||||
return task.upid
|
||||
Reference in New Issue
Block a user