"""Additional node-level handlers with durable ops persistence.""" from __future__ import annotations 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 load_node_ops, save_node_ops from app.tasks.repository import TaskRepository from app.tasks.upid import Upid def _certificates(ops: dict[str, Any]) -> dict[str, Any]: certs = ops.get("certificates") if not isinstance(certs, dict): return {"custom": None, "acme": {}, "info": []} return certs def _hardware(ops: dict[str, Any]) -> dict[str, Any]: hardware = ops.get("hardware") if not isinstance(hardware, dict): return {"pci": [], "usb": [], "mdev": {}} return { "pci": list(hardware.get("pci") or []) if isinstance(hardware.get("pci"), list) else [], "usb": list(hardware.get("usb") or []) if isinstance(hardware.get("usb"), list) else [], "mdev": dict(hardware.get("mdev") or {}) if isinstance(hardware.get("mdev"), dict) else {}, } def _scan_cache(ops: dict[str, Any]) -> dict[str, Any]: scan = ops.get("scan") if not isinstance(scan, dict): return {} return scan def _subscription(ops: dict[str, Any]) -> dict[str, Any]: subscription = ops.get("subscription") if not isinstance(subscription, dict): return {} return subscription def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]: disks = ops.get("disks") if not isinstance(disks, dict): disks = {} ops["disks"] = disks items = disks.get(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) -> str: from app.handlers.nodes import _node_task 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) disks = ops.get("disks") if not isinstance(disks, dict): disks = {} ops["disks"] = disks disks[kind] = items await save_node_ops(request, node, ops) return await _node_task( request, node=node, task_type=f"disk-{kind}-create", worker=f"disk{kind}" ) async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> str: from app.handlers.nodes import _node_task 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) return await _node_task( request, node=node, task_type=f"disk-{kind}-delete", worker=f"diskdel{kind}" ) 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) return await _node_task(request, node=node, task_type="acme", worker="acme") async def certificates_custom( 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) certs = _certificates(ops) if request.method.upper() == "DELETE": certs["custom"] = None ops["certificates"] = certs await save_node_ops(request, node, ops) return None 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 "") filename = str(payload.get("filename") or "pveproxy-ssl.pem") fingerprint = ":".join(secrets.token_hex(1) for _ in range(32)) # SHA-256 fingerprint style summary = { "filename": filename, "fingerprint": fingerprint, "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, "pem": certificates, } certs["custom"] = { "certificates": certificates, "key": key, "restart": int(payload.get("restart") or 0), "filename": filename, } info = list(certs.get("info") or []) info = [item for item in info if item.get("filename") != filename] info.append({key: value for key, value in summary.items() if key != "pem"}) certs["info"] = info ops["certificates"] = certs await save_node_ops(request, node, ops) return summary 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) if not info: from app.simulation.seed import _seed_fingerprint info = [ { "filename": "pve-ssl.pem", "fingerprint": _seed_fingerprint(node), "issuer": f"CN={node}.proxmox.local", "subject": f"CN={node}.proxmox.local", "notbefore": 1_700_000_000, "notafter": 2_000_000_000, "pem": ( "-----BEGIN CERTIFICATE-----\n" "SIMULATOR-SEED-CERT\n" "-----END CERTIFICATE-----\n" ), "san": [f"{node}.proxmox.local"], "public-key-type": "rsa", "public-key-bits": 4096, } ] certs["info"] = info ops["certificates"] = certs await save_node_ops(request, node, ops) result: list[dict[str, Any]] = [] for item in info: entry = dict(item) entry.setdefault("pem", "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----\n") entry.setdefault("san", [str(entry.get("subject", "CN=pve.local")).removeprefix("CN=")]) entry.setdefault("public-key-type", "rsa") entry.setdefault("public-key-bits", 4096) result.append(entry) return result 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]]: node = str(values(inputs)["node"]) await require_node(request, node) ops = await load_node_ops(request, node) caps = ops.get("capabilities") items = caps.get("cpu") if isinstance(caps, dict) else None return [dict(item) for item in items] if isinstance(items, list) else [] async def capabilities_cpu_flags( 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) caps = ops.get("capabilities") items = caps.get("cpu_flags") if isinstance(caps, dict) else None return [dict(item) for item in items] if isinstance(items, list) else [] async def capabilities_machines( 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) caps = ops.get("capabilities") items = caps.get("machines") if isinstance(caps, dict) else None return [dict(item) for item in items] if isinstance(items, list) else [] async def capabilities_migration(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) caps = ops.get("capabilities") migration = caps.get("migration") if isinstance(caps, dict) else None return dict(migration) if isinstance(migration, dict) else {} 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) 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) 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"] = { "status": "notfound", "message": "There is no subscription key", } 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): return [] return [dict(item) for item in items if isinstance(item, dict)] 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.get("apt") if not isinstance(apt, dict): apt = {} ops["apt"] = apt envelope = apt.get("repositories") if isinstance(envelope, dict) and "files" in envelope: files = [ dict(item) for item in list(envelope.get("files") or []) if isinstance(item, dict) ] standard = [ dict(item) for item in list(envelope.get("standard-repos") or []) if isinstance(item, dict) ] else: legacy = list(envelope) if isinstance(envelope, list) else [] files = [ { "path": "/etc/apt/sources.list.d/pve-enterprise.list", "file-type": "list", "digest": "seedapt-file0", "repositories": legacy, } ] standard = [] method = request.method.upper() handle = payload.get("handle") if handle is not None: updated_standard: list[dict[str, Any]] = [] found = False for item in standard: if item.get("handle") == handle: found = True merged = dict(item) if "enabled" in payload: merged["status"] = int(bool(payload.get("enabled"))) updated_standard.append(merged) else: updated_standard.append(item) if not found: updated_standard.append( { "handle": str(handle), "name": str(payload.get("name") or handle), "status": int(bool(payload.get("enabled", 1))), } ) standard = updated_standard elif method == "POST": repo_entry = { key: value for key, value in payload.items() if key not in {"node", "delete", "digest", "path", "handle", "index", "file-type"} } path = str( payload.get("path") or f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list" ) matched = False for file_entry in files: if file_entry.get("path") == path: repos = list(file_entry.get("repositories") or []) repos.append(repo_entry) file_entry["repositories"] = repos matched = True break if not matched: files.append( { "path": path, "file-type": str(payload.get("file-type") or "list"), "digest": secrets.token_hex(4), "repositories": [repo_entry], } ) else: target_path = payload.get("path") index = payload.get("index") for file_idx, file_entry in enumerate(files): if target_path is not None and file_entry.get("path") != target_path: continue repos = [ dict(item) for item in list(file_entry.get("repositories") or []) if isinstance(item, dict) ] if index is not None: idx = int(index) if 0 <= idx < len(repos): repos[idx] = { **repos[idx], **{ key: value for key, value in payload.items() if key not in { "node", "delete", "digest", "path", "handle", "index", "file-type", } }, } elif target_path is not None or file_idx == 0: if repos: repos[0] = { **repos[0], **{ key: value for key, value in payload.items() if key not in { "node", "delete", "digest", "path", "handle", "index", "file-type", } }, } file_entry["repositories"] = repos if target_path is not None: break apt["repositories"] = { "digest": secrets.token_hex(4), "errors": [], "infos": [], "standard-repos": standard, "files": files, } 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") return dict(config) if isinstance(config, dict) else {} 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 {}) 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") return dict(dns) if isinstance(dns, dict) else {} 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 {}) 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 {}) now = int(time.time()) current["time"] = now current["localtime"] = now 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 {}) 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): return {"data": "", "digest": ""} 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) ops = await load_node_ops(request, node) lines = ops.get("journal") if not isinstance(lines, list): return [] sliced = lines[start : start + limit] return [str(item) for item in sliced] 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) ops = await load_node_ops(request, node) lines = ops.get("syslog") if not isinstance(lines, list): return [] return [dict(item) for item in lines[:limit] if isinstance(item, dict)] async def netstat(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("netstat") return [dict(item) for item in items] if isinstance(items, list) else [] async def report(request: Request, inputs: dict[str, Any]) -> str: node = str(values(inputs)["node"]) await require_node(request, node) ops = await load_node_ops(request, node) report_text = ops.get("report") return str(report_text) if report_text is not None else "" async def rrd(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) rrd_state = ops.get("rrd") return dict(rrd_state) if isinstance(rrd_state, dict) else {} async def rrddata(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) series = ops.get("rrddata") return [dict(item) for item in series] if isinstance(series, list) else [] 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"] = [] 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.get("oci_tags") if not isinstance(cache, dict): return [] items = cache.get(repo) if not isinstance(items, list): return [] return [dict(item) for item in items if isinstance(item, dict)] 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.get("url_metadata") if not isinstance(cache, dict): return {} payload = cache.get(url) return dict(payload) if isinstance(payload, dict) else {} 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)