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,972 @@
|
||||
"""Additional node-level handlers with durable ops persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.common import database, require_node, subdirs, values
|
||||
from app.handlers.nodes import default_node_ops, load_node_ops, save_node_ops
|
||||
from app.tasks.repository import TaskRepository
|
||||
from app.tasks.upid import Upid
|
||||
|
||||
DEFAULT_HARDWARE: dict[str, Any] = {
|
||||
"pci": [
|
||||
{
|
||||
"id": "0000:00:1f.2",
|
||||
"vendor_name": "Intel Corporation",
|
||||
"device_name": "SATA Controller",
|
||||
"iommugroup": 0,
|
||||
},
|
||||
{
|
||||
"id": "0000:01:00.0",
|
||||
"vendor_name": "NVIDIA Corporation",
|
||||
"device_name": "GP102 [GeForce GTX 1080 Ti]",
|
||||
"iommugroup": 1,
|
||||
"mdev": 1,
|
||||
},
|
||||
],
|
||||
"usb": [
|
||||
{"busnum": 1, "devnum": 1, "level": 0, "port": "1", "prodid": "0002", "vendid": "1d6b"},
|
||||
{"busnum": 2, "devnum": 2, "level": 1, "port": "2", "prodid": "5591", "vendid": "0781"},
|
||||
],
|
||||
"mdev": {
|
||||
"0000:01:00.0": [
|
||||
{"type": "nvidia-11", "available": 4, "description": "GRID profile"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_SCAN: dict[str, list[dict[str, Any]]] = {
|
||||
"cifs": [{"server": "files.local", "share": "backups"}],
|
||||
"iscsi": [{"portal": "10.0.0.50:3260", "target": "iqn.2024-01.local:storage"}],
|
||||
"lvm": [{"vg": "pve", "size": 500_000_000_000, "free": 100_000_000_000}],
|
||||
"lvmthin": [{"lv": "data", "vg": "pve", "lv_size": 400_000_000_000}],
|
||||
"nfs": [{"server": "nfs.local", "path": "/export/pve", "options": "vers=4"}],
|
||||
"pbs": [{"server": "pbs.local", "datastore": "store1"}],
|
||||
"zfs": [{"pool": "rpool", "name": "rpool/data", "size": 800_000_000_000}],
|
||||
}
|
||||
|
||||
DEFAULT_SUBSCRIPTION: dict[str, Any] = {
|
||||
"status": "notfound",
|
||||
"message": "There is no subscription key",
|
||||
"serverid": "SIMULATOR",
|
||||
"sockets": 1,
|
||||
"productname": "Proxmox VE",
|
||||
"url": "https://www.proxmox.com/en/proxmox-virtual-environment/pricing",
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"description": "Simulator node",
|
||||
"startall-onboot-delay": 0,
|
||||
"wakeonlan": "",
|
||||
}
|
||||
|
||||
DEFAULT_DNS: dict[str, Any] = {
|
||||
"search": "local",
|
||||
"dns1": "1.1.1.1",
|
||||
"dns2": "8.8.8.8",
|
||||
"dns3": "",
|
||||
}
|
||||
|
||||
DEFAULT_TIME: dict[str, Any] = {
|
||||
"timezone": "UTC",
|
||||
"time": 0,
|
||||
"localtime": 0,
|
||||
}
|
||||
|
||||
|
||||
def _certificates(ops: dict[str, Any]) -> dict[str, Any]:
|
||||
certs = ops.setdefault(
|
||||
"certificates",
|
||||
{
|
||||
"custom": None,
|
||||
"acme": {"account": "default", "domains": [], "certificate": None},
|
||||
"info": [],
|
||||
},
|
||||
)
|
||||
if not isinstance(certs, dict):
|
||||
certs = {"custom": None, "acme": {}, "info": []}
|
||||
ops["certificates"] = certs
|
||||
certs.setdefault("acme", {"account": "default", "domains": [], "certificate": None})
|
||||
certs.setdefault("info", [])
|
||||
return certs
|
||||
|
||||
|
||||
def _hardware(ops: dict[str, Any]) -> dict[str, Any]:
|
||||
hardware = ops.get("hardware")
|
||||
if not isinstance(hardware, dict) or not hardware:
|
||||
hardware = copy.deepcopy(DEFAULT_HARDWARE)
|
||||
ops["hardware"] = hardware
|
||||
hardware.setdefault("pci", copy.deepcopy(DEFAULT_HARDWARE["pci"]))
|
||||
hardware.setdefault("usb", copy.deepcopy(DEFAULT_HARDWARE["usb"]))
|
||||
hardware.setdefault("mdev", copy.deepcopy(DEFAULT_HARDWARE["mdev"]))
|
||||
return hardware
|
||||
|
||||
|
||||
def _scan_cache(ops: dict[str, Any]) -> dict[str, Any]:
|
||||
scan = ops.get("scan")
|
||||
if not isinstance(scan, dict) or not scan:
|
||||
scan = copy.deepcopy(DEFAULT_SCAN)
|
||||
ops["scan"] = scan
|
||||
for key, value in DEFAULT_SCAN.items():
|
||||
scan.setdefault(key, copy.deepcopy(value))
|
||||
return scan
|
||||
|
||||
|
||||
def _subscription(ops: dict[str, Any]) -> dict[str, Any]:
|
||||
subscription = ops.get("subscription")
|
||||
if not isinstance(subscription, dict) or not subscription:
|
||||
subscription = copy.deepcopy(DEFAULT_SUBSCRIPTION)
|
||||
ops["subscription"] = subscription
|
||||
return subscription
|
||||
|
||||
|
||||
def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]:
|
||||
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||
if not isinstance(disks, dict):
|
||||
disks = default_node_ops()["disks"]
|
||||
ops["disks"] = disks
|
||||
items = disks.setdefault(kind, [])
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
disks[kind] = items
|
||||
return items
|
||||
|
||||
|
||||
def _public_cert(entry: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if entry is None:
|
||||
return None
|
||||
return {key: value for key, value in entry.items() if key not in {"key", "private-key"}}
|
||||
|
||||
|
||||
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
|
||||
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}:{task_type}",
|
||||
)
|
||||
except ConflictError as error:
|
||||
raise ApiError(409, str(error)) from error
|
||||
return task.upid
|
||||
|
||||
|
||||
async def _set_guest_status(request: Request, node: str, status: str) -> None:
|
||||
await database(request).pool.execute(
|
||||
"""UPDATE resources AS r
|
||||
SET state = jsonb_set(COALESCE(r.state, '{}'::jsonb), '{status}', to_jsonb($2::text), true),
|
||||
updated_at=now()
|
||||
WHERE r.node_id=(SELECT id FROM nodes WHERE name=$1) AND r.kind IN ('qemu', 'lxc')""",
|
||||
node,
|
||||
status,
|
||||
)
|
||||
|
||||
|
||||
async def _migrate_guests(request: Request, node: str, target: str) -> None:
|
||||
target_row = await database(request).pool.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
|
||||
if target_row is None:
|
||||
raise ApiError(404, "target node does not exist")
|
||||
await database(request).pool.execute(
|
||||
"""UPDATE resources SET node_id=$2, updated_at=now()
|
||||
WHERE node_id=(SELECT id FROM nodes WHERE name=$1) AND kind IN ('qemu', 'lxc')""",
|
||||
node,
|
||||
target_row["id"],
|
||||
)
|
||||
|
||||
|
||||
def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
|
||||
async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
name = str(
|
||||
payload.get("name")
|
||||
or payload.get("device")
|
||||
or payload.get("vgname")
|
||||
or payload.get("pool")
|
||||
or f"{kind}-{secrets.token_hex(2)}"
|
||||
)
|
||||
ops = await load_node_ops(request, node)
|
||||
items = _disk_items(ops, kind)
|
||||
if any(str(item.get("name")) == name for item in items):
|
||||
raise ApiError(400, f"{kind} '{name}' already exists")
|
||||
entry = {
|
||||
key: value for key, value in payload.items() if key not in {"node", "delete", "digest"}
|
||||
}
|
||||
entry["name"] = name
|
||||
items.append(entry)
|
||||
ops.setdefault("disks", default_node_ops()["disks"])[kind] = items
|
||||
await save_node_ops(request, node, ops)
|
||||
return entry
|
||||
|
||||
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
name = str(payload["name"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
items = _disk_items(ops, kind)
|
||||
remaining = [item for item in items if str(item.get("name")) != name]
|
||||
if len(remaining) == len(items):
|
||||
raise ApiError(404, f"{kind} does not exist")
|
||||
ops.setdefault("disks", {})[kind] = remaining
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
name = str(payload["name"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
for item in _disk_items(ops, "zfs"):
|
||||
if str(item.get("name")) == name:
|
||||
return dict(item)
|
||||
raise ApiError(404, "zfs pool does not exist")
|
||||
|
||||
async def certificates_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("acme", "custom", "info")
|
||||
|
||||
async def certificates_acme_index(
|
||||
request: Request, inputs: dict[str, Any]
|
||||
) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("certificate")
|
||||
|
||||
async def certificates_acme_mutate(request: Request, inputs: dict[str, Any]) -> str | None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
certs = _certificates(ops)
|
||||
acme = dict(certs.get("acme") or {})
|
||||
method = request.method.upper()
|
||||
if method == "DELETE":
|
||||
acme["certificate"] = None
|
||||
acme["domains"] = []
|
||||
else:
|
||||
domains = payload.get("domains") or payload.get("domain") or acme.get("domains") or []
|
||||
if isinstance(domains, str):
|
||||
domains = [part.strip() for part in domains.split(",") if part.strip()]
|
||||
acme["domains"] = list(domains)
|
||||
acme["account"] = str(payload.get("account") or acme.get("account") or "default")
|
||||
acme["certificate"] = {
|
||||
"pem": str(payload.get("certificates") or payload.get("certificate") or "SIM-ACME"),
|
||||
"issued": int(time.time()),
|
||||
}
|
||||
certs["acme"] = acme
|
||||
ops["certificates"] = certs
|
||||
await save_node_ops(request, node, ops)
|
||||
if method == "DELETE":
|
||||
return None
|
||||
return await _node_task(request, node=node, task_type="acme", worker="acme")
|
||||
|
||||
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
certs = _certificates(ops)
|
||||
if request.method.upper() == "DELETE":
|
||||
certs["custom"] = None
|
||||
else:
|
||||
certificates = str(payload.get("certificates") or payload.get("cert") or "")
|
||||
if not certificates:
|
||||
raise ApiError(400, "parameter verification failed - 'certificates' missing")
|
||||
key = str(payload.get("key") or payload.get("private-key") or "")
|
||||
certs["custom"] = {
|
||||
"certificates": certificates,
|
||||
"key": key,
|
||||
"restart": int(payload.get("restart") or 0),
|
||||
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"),
|
||||
}
|
||||
info = list(certs.get("info") or [])
|
||||
info = [item for item in info if item.get("filename") != certs["custom"]["filename"]]
|
||||
info.append(
|
||||
{
|
||||
"filename": certs["custom"]["filename"],
|
||||
"fingerprint": secrets.token_hex(20),
|
||||
"issuer": "CN=Simulator",
|
||||
"subject": "CN=pve.local",
|
||||
"notbefore": int(time.time()) - 86_400,
|
||||
"notafter": int(time.time()) + 365 * 86_400,
|
||||
"san": ["DNS:pve.local"],
|
||||
"public-key-type": "rsa",
|
||||
"public-key-bits": 2048,
|
||||
}
|
||||
)
|
||||
certs["info"] = info
|
||||
ops["certificates"] = certs
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
certs = _certificates(ops)
|
||||
info = list(certs.get("info") or [])
|
||||
custom = _public_cert(
|
||||
certs.get("custom") if isinstance(certs.get("custom"), dict) else None
|
||||
)
|
||||
if custom and not any(item.get("filename") == custom.get("filename") for item in info):
|
||||
info.append(
|
||||
{
|
||||
"filename": custom.get("filename", "pveproxy-ssl.pem"),
|
||||
"fingerprint": secrets.token_hex(20),
|
||||
"issuer": "CN=Custom",
|
||||
"subject": "CN=pve.local",
|
||||
}
|
||||
)
|
||||
certs["info"] = info
|
||||
ops["certificates"] = certs
|
||||
await save_node_ops(request, node, ops)
|
||||
return [dict(item) for item in info]
|
||||
|
||||
async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("cifs", "iscsi", "lvm", "lvmthin", "nfs", "pbs", "zfs")
|
||||
|
||||
async def scan_kind(
|
||||
request: Request, inputs: dict[str, Any], kind: str
|
||||
) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
scan = _scan_cache(ops)
|
||||
ops["scan"] = scan
|
||||
await save_node_ops(request, node, ops)
|
||||
items = scan.get(kind, [])
|
||||
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||
|
||||
async def capabilities_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("qemu")
|
||||
|
||||
async def capabilities_qemu(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("cpu", "cpu-flags", "machines", "migration")
|
||||
|
||||
async def capabilities_cpu(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return [
|
||||
{"name": "host", "vendor": "QEMU", "custom": 0},
|
||||
{"name": "x86-64-v2-AES", "vendor": "QEMU", "custom": 0},
|
||||
{"name": "kvm64", "vendor": "QEMU", "custom": 0},
|
||||
]
|
||||
|
||||
async def capabilities_cpu_flags(
|
||||
request: Request, inputs: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return [
|
||||
{"name": "aes", "introduces": "Westmere"},
|
||||
{"name": "avx", "introduces": "SandyBridge"},
|
||||
{"name": "avx2", "introduces": "Haswell"},
|
||||
]
|
||||
|
||||
async def capabilities_machines(
|
||||
request: Request, inputs: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return [
|
||||
{"id": "pc-i440fx-9.0", "type": "i440fx", "version": "9.0"},
|
||||
{"id": "pc-q35-9.0", "type": "q35", "version": "9.0"},
|
||||
]
|
||||
|
||||
async def capabilities_migration(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return {"network": "", "type": "secure", "enabled": 1}
|
||||
|
||||
async def hardware_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||
await require_node(request, str(values(inputs)["node"]))
|
||||
return subdirs("pci", "usb")
|
||||
|
||||
async def hardware_pci(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
hardware = _hardware(ops)
|
||||
ops["hardware"] = hardware
|
||||
await save_node_ops(request, node, ops)
|
||||
return [dict(item) for item in hardware.get("pci", [])]
|
||||
|
||||
async def hardware_pci_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
for item in _hardware(ops).get("pci", []):
|
||||
if str(item.get("id")) == pci_id:
|
||||
return dict(item)
|
||||
raise ApiError(404, "pci device does not exist")
|
||||
|
||||
async def hardware_pci_mdev(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
hardware = _hardware(ops)
|
||||
mdev = hardware.get("mdev", {})
|
||||
items = mdev.get(pci_id, []) if isinstance(mdev, dict) else []
|
||||
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||
|
||||
async def hardware_usb(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
hardware = _hardware(ops)
|
||||
ops["hardware"] = hardware
|
||||
await save_node_ops(request, node, ops)
|
||||
return [dict(item) for item in hardware.get("usb", [])]
|
||||
|
||||
async def subscription_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
public = dict(_subscription(ops))
|
||||
public.pop("key", None)
|
||||
return public
|
||||
|
||||
async def subscription_mutate(
|
||||
request: Request, inputs: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
current = _subscription(ops)
|
||||
method = request.method.upper()
|
||||
if method == "DELETE":
|
||||
ops["subscription"] = copy.deepcopy(DEFAULT_SUBSCRIPTION)
|
||||
await save_node_ops(request, node, ops)
|
||||
return None
|
||||
if method == "POST":
|
||||
current["checktime"] = int(time.time())
|
||||
current["status"] = current.get("status") or "Active"
|
||||
ops["subscription"] = current
|
||||
await save_node_ops(request, node, ops)
|
||||
return dict(current)
|
||||
key = str(payload.get("key") or current.get("key") or "")
|
||||
updated = {
|
||||
**current,
|
||||
**{k: v for k, v in payload.items() if k not in {"node", "delete", "digest"}},
|
||||
"key": key,
|
||||
"status": "Active" if key else current.get("status", "notfound"),
|
||||
"message": "OK" if key else current.get("message", "There is no subscription key"),
|
||||
}
|
||||
ops["subscription"] = updated
|
||||
await save_node_ops(request, node, ops)
|
||||
public = dict(updated)
|
||||
public.pop("key", None)
|
||||
return public
|
||||
|
||||
async def aplinfo_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
items = ops.get("aplinfo")
|
||||
if not isinstance(items, list):
|
||||
items = [
|
||||
{
|
||||
"package": "alpine-3-standard",
|
||||
"section": "system",
|
||||
"type": "lxc",
|
||||
"version": "3.20",
|
||||
}
|
||||
]
|
||||
ops["aplinfo"] = items
|
||||
await save_node_ops(request, node, ops)
|
||||
return [dict(item) for item in items]
|
||||
|
||||
async def aplinfo_download(request: Request, inputs: dict[str, Any]) -> str:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
downloads = list(ops.get("aplinfo_downloads") or [])
|
||||
downloads.append(
|
||||
{
|
||||
"template": str(payload.get("template") or payload.get("storage") or "unknown"),
|
||||
"at": int(time.time()),
|
||||
}
|
||||
)
|
||||
ops["aplinfo_downloads"] = downloads
|
||||
await save_node_ops(request, node, ops)
|
||||
return await _node_task(request, node=node, task_type="download", worker="download")
|
||||
|
||||
async def apt_repositories_mutate(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
apt = ops.setdefault("apt", copy.deepcopy(default_node_ops()["apt"]))
|
||||
repositories = list(apt.get("repositories") or [])
|
||||
method = request.method.upper()
|
||||
if method == "POST":
|
||||
entry = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "delete", "digest"}
|
||||
}
|
||||
entry.setdefault("path", f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list")
|
||||
entry.setdefault("enabled", 1)
|
||||
repositories.append(entry)
|
||||
else:
|
||||
path = payload.get("path")
|
||||
handle = payload.get("handle")
|
||||
index = payload.get("index")
|
||||
updated: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(repositories):
|
||||
match = False
|
||||
if path is not None and item.get("path") == path:
|
||||
match = True
|
||||
if handle is not None and item.get("handle") == handle:
|
||||
match = True
|
||||
if index is not None and idx == int(index):
|
||||
match = True
|
||||
if match or (path is None and handle is None and index is None and idx == 0):
|
||||
merged = {
|
||||
**item,
|
||||
**{
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "delete", "digest", "path", "handle", "index"}
|
||||
},
|
||||
}
|
||||
updated.append(merged)
|
||||
else:
|
||||
updated.append(item)
|
||||
repositories = updated
|
||||
apt["repositories"] = repositories
|
||||
ops["apt"] = apt
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def node_config_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
config = ops.get("config")
|
||||
if not isinstance(config, dict):
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
ops["config"] = config
|
||||
await save_node_ops(request, node, ops)
|
||||
return dict(config)
|
||||
|
||||
async def node_config_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
config = dict(ops.get("config") or DEFAULT_CONFIG)
|
||||
config.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "digest", "delete"}
|
||||
}
|
||||
)
|
||||
ops["config"] = config
|
||||
await save_node_ops(request, node, ops)
|
||||
return config
|
||||
|
||||
async def dns_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
dns = ops.get("dns")
|
||||
if not isinstance(dns, dict):
|
||||
dns = copy.deepcopy(DEFAULT_DNS)
|
||||
ops["dns"] = dns
|
||||
await save_node_ops(request, node, ops)
|
||||
return dict(dns)
|
||||
|
||||
async def dns_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
dns = dict(ops.get("dns") or DEFAULT_DNS)
|
||||
dns.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in {"node", "digest", "delete"}
|
||||
}
|
||||
)
|
||||
ops["dns"] = dns
|
||||
await save_node_ops(request, node, ops)
|
||||
return dns
|
||||
|
||||
async def time_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
current = dict(ops.get("time") or DEFAULT_TIME)
|
||||
now = int(time.time())
|
||||
current["time"] = now
|
||||
current["localtime"] = now
|
||||
current.setdefault("timezone", "UTC")
|
||||
ops["time"] = current
|
||||
await save_node_ops(request, node, ops)
|
||||
return current
|
||||
|
||||
async def time_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
current = dict(ops.get("time") or DEFAULT_TIME)
|
||||
if "timezone" in payload:
|
||||
current["timezone"] = str(payload["timezone"])
|
||||
now = int(time.time())
|
||||
current["time"] = now
|
||||
current["localtime"] = now
|
||||
ops["time"] = current
|
||||
await save_node_ops(request, node, ops)
|
||||
return current
|
||||
|
||||
async def execute(request: Request, inputs: dict[str, Any]) -> list[str]:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
commands = payload.get("commands") or payload.get("command") or []
|
||||
if isinstance(commands, str):
|
||||
try:
|
||||
parsed = json.loads(commands)
|
||||
commands = parsed if isinstance(parsed, list) else [commands]
|
||||
except json.JSONDecodeError:
|
||||
commands = [commands]
|
||||
ops = await load_node_ops(request, node)
|
||||
log = list(ops.get("execute_log") or [])
|
||||
output: list[str] = []
|
||||
for command in commands:
|
||||
entry = {"command": str(command), "at": int(time.time())}
|
||||
log.append(entry)
|
||||
output.append(f"OK: {command}")
|
||||
ops["execute_log"] = log[-100:]
|
||||
await save_node_ops(request, node, ops)
|
||||
return output
|
||||
|
||||
async def hosts_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
hosts = ops.get("hosts")
|
||||
if not isinstance(hosts, dict):
|
||||
hosts = {
|
||||
"data": f"127.0.0.1 localhost\n10.0.0.10 {node}\n",
|
||||
"digest": secrets.token_hex(8),
|
||||
}
|
||||
ops["hosts"] = hosts
|
||||
await save_node_ops(request, node, ops)
|
||||
return {"data": str(hosts.get("data", "")), "digest": str(hosts.get("digest", ""))}
|
||||
|
||||
async def hosts_post(request: Request, inputs: dict[str, Any]) -> None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
ops["hosts"] = {
|
||||
"data": str(payload.get("data") or ""),
|
||||
"digest": secrets.token_hex(8),
|
||||
}
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def journal(request: Request, inputs: dict[str, Any]) -> list[str]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
start = int(values(inputs).get("startcursor") or values(inputs).get("start") or 0)
|
||||
limit = int(values(inputs).get("limit") or 50)
|
||||
lines = [
|
||||
f"{index}: {node} systemd[1]: Started simulated service {index}."
|
||||
for index in range(start, start + limit)
|
||||
]
|
||||
return lines
|
||||
|
||||
async def syslog(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
limit = int(values(inputs).get("limit") or 50)
|
||||
return [
|
||||
{"n": index, "t": f"{node} kernel: simulated syslog line {index}"}
|
||||
for index in range(limit)
|
||||
]
|
||||
|
||||
async def netstat(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
return [
|
||||
{"in": 1_000_000, "out": 900_000, "vnet": "vmbr0", "hwaddr": "bc:24:11:00:00:01"},
|
||||
{"in": 500_000, "out": 450_000, "vnet": "vmbr1", "hwaddr": "bc:24:11:00:00:02"},
|
||||
]
|
||||
|
||||
async def report(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
return f"==== Proxmox node report for {node} ====\nuptime: simulated\n"
|
||||
|
||||
async def rrd(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
await require_node(_request, str(values(inputs)["node"]))
|
||||
return {"filename": "/var/lib/rrdcached/db/pve-node.rrd"}
|
||||
|
||||
async def rrddata(_request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
await require_node(_request, str(values(inputs)["node"]))
|
||||
now = int(time.time())
|
||||
return [
|
||||
{"time": now - 120, "cpu": 0.05, "memused": 1_000_000_000},
|
||||
{"time": now - 60, "cpu": 0.07, "memused": 1_100_000_000},
|
||||
{"time": now, "cpu": 0.04, "memused": 1_050_000_000},
|
||||
]
|
||||
|
||||
async def startall(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
await _set_guest_status(request, node, "running")
|
||||
return await _node_task(request, node=node, task_type="startall", worker="startall")
|
||||
|
||||
async def stopall(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
await _set_guest_status(request, node, "stopped")
|
||||
return await _node_task(request, node=node, task_type="stopall", worker="stopall")
|
||||
|
||||
async def suspendall(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
await _set_guest_status(request, node, "paused")
|
||||
return await _node_task(request, node=node, task_type="suspendall", worker="suspendall")
|
||||
|
||||
async def migrateall(request: Request, inputs: dict[str, Any]) -> str:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
target = str(payload.get("target") or "")
|
||||
await require_node(request, node)
|
||||
if not target:
|
||||
raise ApiError(400, "parameter verification failed - 'target' missing")
|
||||
await _migrate_guests(request, node, target)
|
||||
return await _node_task(request, node=node, task_type="migrateall", worker="migrateall")
|
||||
|
||||
async def status_post(request: Request, inputs: dict[str, Any]) -> str | None:
|
||||
payload = values(inputs)
|
||||
node = str(payload["node"])
|
||||
await require_node(request, node)
|
||||
command = str(payload.get("command") or "reboot")
|
||||
ops = await load_node_ops(request, node)
|
||||
ops["last_status_command"] = {"command": command, "at": int(time.time())}
|
||||
await save_node_ops(request, node, ops)
|
||||
return await _node_task(request, node=node, task_type=command, worker=command)
|
||||
|
||||
async def wakeonlan(request: Request, inputs: dict[str, Any]) -> str:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
ops["wakeonlan"] = {"at": int(time.time())}
|
||||
await save_node_ops(request, node, ops)
|
||||
return "OK"
|
||||
|
||||
async def _shell_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
payload = {
|
||||
"port": 5900 if kind == "vnc" else 22 if kind == "term" else 3128,
|
||||
"ticket": secrets.token_urlsafe(24),
|
||||
"user": str(getattr(request.state, "principal", "root@pam")),
|
||||
"upid": f"UPID:{node}:{secrets.token_hex(4)}:{kind}shell:0:root@pam:",
|
||||
}
|
||||
ops = await load_node_ops(request, node)
|
||||
shells = ops.setdefault("shells", {})
|
||||
shells[kind] = {key: value for key, value in payload.items() if key != "ticket"}
|
||||
ops["shells"] = shells
|
||||
await save_node_ops(request, node, ops)
|
||||
return payload
|
||||
|
||||
async def spiceshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return await _shell_proxy(request, inputs, "spice")
|
||||
|
||||
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return await _shell_proxy(request, inputs, "term")
|
||||
|
||||
async def vncshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return await _shell_proxy(request, inputs, "vnc")
|
||||
|
||||
async def network_reload(request: Request, inputs: dict[str, Any]) -> None:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
if not isinstance(ops.get("network"), list):
|
||||
ops["network"] = copy.deepcopy(default_node_ops()["network"])
|
||||
ops["network_applied"] = False
|
||||
await save_node_ops(request, node, ops)
|
||||
|
||||
async def query_oci_repo_tags(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
repo = str(values(inputs).get("repo") or "library/alpine")
|
||||
ops = await load_node_ops(request, node)
|
||||
cache = ops.setdefault("oci_tags", {})
|
||||
if repo not in cache:
|
||||
cache[repo] = [{"tag": "latest"}, {"tag": "3.20"}]
|
||||
ops["oci_tags"] = cache
|
||||
await save_node_ops(request, node, ops)
|
||||
return [dict(item) for item in cache[repo]]
|
||||
|
||||
async def query_url_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
url = str(values(inputs).get("url") or "")
|
||||
ops = await load_node_ops(request, node)
|
||||
cache = ops.setdefault("url_metadata", {})
|
||||
if url not in cache:
|
||||
cache[url] = {
|
||||
"filename": url.rsplit("/", 1)[-1] or "download.bin",
|
||||
"mimetype": "application/octet-stream",
|
||||
"size": 1024,
|
||||
}
|
||||
ops["url_metadata"] = cache
|
||||
await save_node_ops(request, node, ops)
|
||||
return dict(cache[url])
|
||||
|
||||
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
node = str(values(inputs)["node"])
|
||||
await require_node(request, node)
|
||||
ops = await load_node_ops(request, node)
|
||||
shell = (ops.get("shells") or {}).get("vnc") or {"port": 5900}
|
||||
return {
|
||||
"port": shell.get("port", 5900),
|
||||
"ticket": secrets.token_urlsafe(24),
|
||||
}
|
||||
|
||||
# Disks mutations (GET collections already registered in nodes.py)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/directory",
|
||||
"POST",
|
||||
lambda request, inputs: disks_create(request, inputs, "directory"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/directory/{name}",
|
||||
"DELETE",
|
||||
lambda request, inputs: disks_delete(request, inputs, "directory"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/lvm",
|
||||
"POST",
|
||||
lambda request, inputs: disks_create(request, inputs, "lvm"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/lvm/{name}",
|
||||
"DELETE",
|
||||
lambda request, inputs: disks_delete(request, inputs, "lvm"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/lvmthin",
|
||||
"POST",
|
||||
lambda request, inputs: disks_create(request, inputs, "lvmthin"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/lvmthin/{name}",
|
||||
"DELETE",
|
||||
lambda request, inputs: disks_delete(request, inputs, "lvmthin"),
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/zfs",
|
||||
"POST",
|
||||
lambda request, inputs: disks_create(request, inputs, "zfs"),
|
||||
)
|
||||
registry.register("/nodes/{node}/disks/zfs/{name}", "GET", disks_zfs_get)
|
||||
registry.register(
|
||||
"/nodes/{node}/disks/zfs/{name}",
|
||||
"DELETE",
|
||||
lambda request, inputs: disks_delete(request, inputs, "zfs"),
|
||||
)
|
||||
|
||||
registry.register("/nodes/{node}/certificates", "GET", certificates_index)
|
||||
registry.register("/nodes/{node}/certificates/acme", "GET", certificates_acme_index)
|
||||
registry.register(
|
||||
"/nodes/{node}/certificates/acme/certificate", "POST", certificates_acme_mutate
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/certificates/acme/certificate", "PUT", certificates_acme_mutate
|
||||
)
|
||||
registry.register(
|
||||
"/nodes/{node}/certificates/acme/certificate", "DELETE", certificates_acme_mutate
|
||||
)
|
||||
registry.register("/nodes/{node}/certificates/custom", "POST", certificates_custom)
|
||||
registry.register("/nodes/{node}/certificates/custom", "DELETE", certificates_custom)
|
||||
registry.register("/nodes/{node}/certificates/info", "GET", certificates_info)
|
||||
|
||||
registry.register("/nodes/{node}/scan", "GET", scan_index)
|
||||
registry.register("/nodes/{node}/scan/cifs", "GET", lambda r, i: scan_kind(r, i, "cifs"))
|
||||
registry.register("/nodes/{node}/scan/iscsi", "GET", lambda r, i: scan_kind(r, i, "iscsi"))
|
||||
registry.register("/nodes/{node}/scan/lvm", "GET", lambda r, i: scan_kind(r, i, "lvm"))
|
||||
registry.register("/nodes/{node}/scan/lvmthin", "GET", lambda r, i: scan_kind(r, i, "lvmthin"))
|
||||
registry.register("/nodes/{node}/scan/nfs", "GET", lambda r, i: scan_kind(r, i, "nfs"))
|
||||
registry.register("/nodes/{node}/scan/pbs", "GET", lambda r, i: scan_kind(r, i, "pbs"))
|
||||
registry.register("/nodes/{node}/scan/zfs", "GET", lambda r, i: scan_kind(r, i, "zfs"))
|
||||
|
||||
registry.register("/nodes/{node}/capabilities", "GET", capabilities_index)
|
||||
registry.register("/nodes/{node}/capabilities/qemu", "GET", capabilities_qemu)
|
||||
registry.register("/nodes/{node}/capabilities/qemu/cpu", "GET", capabilities_cpu)
|
||||
registry.register("/nodes/{node}/capabilities/qemu/cpu-flags", "GET", capabilities_cpu_flags)
|
||||
registry.register("/nodes/{node}/capabilities/qemu/machines", "GET", capabilities_machines)
|
||||
registry.register("/nodes/{node}/capabilities/qemu/migration", "GET", capabilities_migration)
|
||||
|
||||
registry.register("/nodes/{node}/hardware", "GET", hardware_index)
|
||||
registry.register("/nodes/{node}/hardware/pci", "GET", hardware_pci)
|
||||
registry.register("/nodes/{node}/hardware/pci/{pci-id-or-mapping}", "GET", hardware_pci_get)
|
||||
registry.register(
|
||||
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev", "GET", hardware_pci_mdev
|
||||
)
|
||||
registry.register("/nodes/{node}/hardware/usb", "GET", hardware_usb)
|
||||
|
||||
registry.register("/nodes/{node}/subscription", "GET", subscription_get)
|
||||
registry.register("/nodes/{node}/subscription", "PUT", subscription_mutate)
|
||||
registry.register("/nodes/{node}/subscription", "POST", subscription_mutate)
|
||||
registry.register("/nodes/{node}/subscription", "DELETE", subscription_mutate)
|
||||
|
||||
registry.register("/nodes/{node}/aplinfo", "GET", aplinfo_get)
|
||||
registry.register("/nodes/{node}/aplinfo", "POST", aplinfo_download)
|
||||
registry.register("/nodes/{node}/apt/repositories", "POST", apt_repositories_mutate)
|
||||
registry.register("/nodes/{node}/apt/repositories", "PUT", apt_repositories_mutate)
|
||||
registry.register("/nodes/{node}/config", "GET", node_config_get)
|
||||
registry.register("/nodes/{node}/config", "PUT", node_config_put)
|
||||
registry.register("/nodes/{node}/dns", "GET", dns_get)
|
||||
registry.register("/nodes/{node}/dns", "PUT", dns_put)
|
||||
registry.register("/nodes/{node}/time", "GET", time_get)
|
||||
registry.register("/nodes/{node}/time", "PUT", time_put)
|
||||
registry.register("/nodes/{node}/execute", "POST", execute)
|
||||
registry.register("/nodes/{node}/hosts", "GET", hosts_get)
|
||||
registry.register("/nodes/{node}/hosts", "POST", hosts_post)
|
||||
registry.register("/nodes/{node}/journal", "GET", journal)
|
||||
registry.register("/nodes/{node}/syslog", "GET", syslog)
|
||||
registry.register("/nodes/{node}/netstat", "GET", netstat)
|
||||
registry.register("/nodes/{node}/report", "GET", report)
|
||||
registry.register("/nodes/{node}/rrd", "GET", rrd)
|
||||
registry.register("/nodes/{node}/rrddata", "GET", rrddata)
|
||||
registry.register("/nodes/{node}/migrateall", "POST", migrateall)
|
||||
registry.register("/nodes/{node}/startall", "POST", startall)
|
||||
registry.register("/nodes/{node}/stopall", "POST", stopall)
|
||||
registry.register("/nodes/{node}/suspendall", "POST", suspendall)
|
||||
registry.register("/nodes/{node}/status", "POST", status_post)
|
||||
registry.register("/nodes/{node}/wakeonlan", "POST", wakeonlan)
|
||||
registry.register("/nodes/{node}/spiceshell", "POST", spiceshell)
|
||||
registry.register("/nodes/{node}/termproxy", "POST", termproxy)
|
||||
registry.register("/nodes/{node}/vncshell", "POST", vncshell)
|
||||
registry.register("/nodes/{node}/network", "DELETE", network_reload)
|
||||
registry.register("/nodes/{node}/query-oci-repo-tags", "GET", query_oci_repo_tags)
|
||||
registry.register("/nodes/{node}/query-url-metadata", "GET", query_url_metadata)
|
||||
registry.register("/nodes/{node}/vncwebsocket", "GET", vncwebsocket)
|
||||
Reference in New Issue
Block a user