6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
973 lines
41 KiB
Python
973 lines
41 KiB
Python
"""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)
|