Prepare 0.1.0 for lab release: durable handlers, HTTP Compose, CI, and pulumi-tests.

- Harden DB-backed handlers and seed profiles; align client wire shapes for
  cluster resources, QEMU config, and node SSL fields
- Serve plain HTTP on Compose :8006; keep TLS optional (--profile tls) and
  terminate HTTPS at Kubernetes Ingress
- Add pulumi-tests (full contract surface majors 6–9 + BPG lifecycle) and
  make pulumi-tests
- Ship bilingual docs, CHANGELOG, SECURITY, CONTRIBUTING, and GitHub Actions
  (make ci + Compose/Helm validation)
This commit is contained in:
Sergey Antropoff
2026-07-18 04:18:05 +03:00
parent 777926487b
commit 48df10b17e
172 changed files with 7528 additions and 1208 deletions
+66 -2
View File
@@ -17,7 +17,7 @@ from app.api.errors import ApiError, ContractValidationError
from app.api.openapi import contract_openapi_tags
from app.config import Settings
from app.contracts.examples import schema_example
from app.contracts.model import Method, Schema, Snapshot
from app.contracts.model import Method, Parameter, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
@@ -137,6 +137,64 @@ def register_legacy_handler_routes(
return seen
_PATH_PARAM_RE = re.compile(r"\{([^{}]+)\}")
def _unbound_method(path: str, verb: str) -> Method:
"""Minimal contract method so handler-only paths can be mounted."""
parameters = tuple(
Parameter(name=name, definition=Schema(type="string", optional=True))
for name in _PATH_PARAM_RE.findall(path)
)
return Method(
verb=verb.upper(),
name=f"unbound-{verb.lower()}-{path}",
description="Handler-backed route not present in the active contract snapshot",
parameters=parameters,
returns=Schema(),
permissions=None,
checksum="0" * 64,
)
def register_unbound_handler_routes(
app: FastAPI,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
*,
existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
"""Mount semantic handlers that no cached contract currently declares.
Keeps DB-backed handlers reachable (no FastAPI 404) when Proxmox schema
omits a path that the simulator still implements.
"""
seen = existing if existing is not None else set()
for path, verb in sorted(handlers.keys()):
for renderer in ("json", "extjs"):
route = f"/api2/{renderer}{path}"
key = (route, verb, renderer)
if key in seen:
continue
method = _unbound_method(path, verb)
seen.add(key)
app.add_api_route(
route,
_endpoint(path, method, renderer, handlers, fallback),
methods=[verb],
name=f"unbound:{renderer}:{verb}:{path}",
tags=cast(list[str | Enum], contract_openapi_tags(path, renderer)),
openapi_extra={
"x-proxmox-method-checksum": method.checksum,
"x-proxmox-implementation": "implemented",
"x-proxmox-unbound-handler": True,
},
)
return seen
def _endpoint(
semantic_path: str,
method: Method,
@@ -302,8 +360,14 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
continue
if name not in supplied:
if definition.optional:
# Proxmox schema ``default`` is often UI documentation text (e.g.
# bwlimit default = "restore limit from datacenter…"), not a value
# to materialize. Only inject defaults that coerce to the type.
if definition.default is not None:
parsed[name] = definition.default
try:
parsed[name] = _coerce(definition.default, definition)
except (TypeError, ValueError):
pass
continue
errors[name] = "property is missing and it is not optional"
continue
+3 -1
View File
@@ -14,6 +14,7 @@ from app.api.registry import (
HandlerRegistry,
register_contract_routes,
register_legacy_handler_routes,
register_unbound_handler_routes,
)
from app.compatibility import (
CompatibilityDimension,
@@ -106,7 +107,7 @@ def apply_runtime_contract(
clear_contract_routes(app)
registered = register_contract_routes(app, snapshot, handlers, fallback)
register_legacy_handler_routes(
registered = register_legacy_handler_routes(
app,
handlers,
store_root,
@@ -114,6 +115,7 @@ def apply_runtime_contract(
primary_version=snapshot.source_version,
existing=registered,
)
register_unbound_handler_routes(app, handlers, fallback, existing=registered)
report = build_compatibility_for_snapshot(
snapshot,
handlers,
+45 -47
View File
@@ -10,41 +10,34 @@ from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
_DEFAULT_DIRECTORIES = [
{
"name": "Let's Encrypt V2",
"url": "https://acme-v02.api.letsencrypt.org/directory",
},
{
"name": "Let's Encrypt V2 Staging",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
},
]
_CHALLENGE_SCHEMA = [
{
"id": "dns",
"name": "DNS plugin",
"type": "dns",
"fields": [{"name": "api", "type": "string"}],
}
]
def _acme(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"acme",
{"accounts": {}, "plugins": {}, "meta": {}},
)
current = metadata.get("acme")
if not isinstance(current, dict):
current = {"accounts": {}, "plugins": {}, "meta": {}}
current = {}
metadata["acme"] = current
current.setdefault("accounts", {})
current.setdefault("plugins", {})
current.setdefault("meta", {})
if not isinstance(current.get("accounts"), dict):
current["accounts"] = {}
if not isinstance(current.get("plugins"), dict):
current["plugins"] = {}
if not isinstance(current.get("meta"), dict):
current["meta"] = {}
if not isinstance(current.get("directories"), list):
current["directories"] = []
if not isinstance(current.get("challenge_schema"), list):
current["challenge_schema"] = []
return current
def _default_directory(acme: dict[str, Any]) -> str:
directories = acme.get("directories")
if isinstance(directories, list):
for item in directories:
if isinstance(item, dict) and item.get("url"):
return str(item["url"])
return ""
def register_acme_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
@@ -72,16 +65,17 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
name = str(payload.get("name") or "default")
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
acme = _acme(metadata)
accounts = acme["accounts"]
if name in accounts:
raise ApiError(400, f"ACME account '{name}' already exists")
directory = str(payload.get("directory") or _default_directory(acme))
accounts[name] = {
"name": name,
"contact": payload.get("contact"),
"directory": payload.get("directory") or _DEFAULT_DIRECTORIES[0]["url"],
"directory": directory,
"tos_url": payload.get("tos_url"),
"eab-kid": payload.get("eab-kid"),
# eab-hmac-key stored but never returned
"eab-hmac-key": payload.get("eab-hmac-key"),
"location": f"https://acme.example.local/acct/{name}",
}
@@ -181,29 +175,33 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
del plugins[plugin_id]
await save_cluster_metadata(request, metadata)
async def directories(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_DEFAULT_DIRECTORIES)
async def directories(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
directories = _acme(metadata).get("directories")
if isinstance(directories, list):
return [dict(item) for item in directories if isinstance(item, dict)]
return []
async def challenge_schema(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_CHALLENGE_SCHEMA)
async def challenge_schema(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
schema = _acme(metadata).get("challenge_schema")
if isinstance(schema, list):
return [dict(item) for item in schema if isinstance(item, dict)]
return []
async def meta(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
metadata = await cluster_metadata(request)
meta_store = _acme(metadata).setdefault("meta", {})
payload = meta_store.setdefault(
directory,
{
"termsOfService": f"{directory.rstrip('/')}/tos",
"caaIdentities": ["letsencrypt.org"],
},
)
await save_cluster_metadata(request, metadata)
acme = _acme(metadata)
directory = str(values(inputs).get("directory") or _default_directory(acme))
meta_raw = acme.get("meta")
meta_store: dict[str, Any] = dict(meta_raw) if isinstance(meta_raw, dict) else {}
payload = meta_store.get(directory)
if not isinstance(payload, dict):
return {}
return dict(payload)
async def tos(request: Request, inputs: dict[str, Any]) -> str:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
result = await meta(request, {"values": {"directory": directory}, "provided": frozenset()})
result = await meta(request, inputs)
return str(result.get("termsOfService") or "")
registry.register("/cluster/acme", "GET", index)
+19 -14
View File
@@ -154,26 +154,31 @@ def register_backup_handlers(registry: HandlerRegistry) -> None:
vmid = row["vmid"]
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
async def vzdump_defaults(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(_request, str(values(inputs)["node"]))
return {
"all": 0,
"bwlimit": 0,
"compress": "zstd",
"dumpdir": "backup",
"mode": "snapshot",
"remove": 0,
"storage": "nfs-backup",
"mailto": "",
"notes-template": "{{guestname}}",
}
async def vzdump_defaults(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.nodes import load_node_ops
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
vzdump = ops.get("vzdump")
defaults = vzdump.get("defaults") if isinstance(vzdump, dict) else None
return dict(defaults) if isinstance(defaults, dict) else {}
async def vzdump_extractconfig(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.nodes import load_node_ops
payload = values(inputs)
node = str(payload["node"])
volid = str(payload.get("volume") or payload.get("volid") or "")
if not volid:
raise ApiError(400, "volume parameter required")
return f"# simulated vzdump config extracted from {volid}\name: demo\nmemory: 2048\n"
await require_node(request, node)
ops = await load_node_ops(request, node)
vzdump = ops.get("vzdump")
configs = vzdump.get("extractconfig") if isinstance(vzdump, dict) else None
if not isinstance(configs, dict):
return ""
return str(configs.get(volid) or "")
async def vzdump_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
+65 -90
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import secrets
from typing import Any, cast
from typing import Any
from fastapi import Request
@@ -21,37 +21,6 @@ from app.handlers.common import (
)
from app.simulation.seed import CLUSTER_ID
DEFAULT_CLUSTER_CEPH = {
"initialized": True,
"config": {
"network": "10.10.10.0/24",
"cluster-network": "10.10.10.0/24",
"size": 3,
"min_size": 2,
"pg_bits": 7,
},
"cfg_db": [
{"section": "global", "name": "auth_client_required", "value": "cephx"},
{"section": "global", "name": "fsid", "value": "pve-simulator-fsid"},
],
"cfg_raw": "[global]\nfsid = pve-simulator-fsid\nauth_client_required = cephx\n",
"cfg_values": {},
"pools": {
"rbd": {
"pool": "rbd",
"size": 3,
"min_size": 2,
"pg_num": 128,
"application": "rbd",
"crush_rule": "replicated_rule",
}
},
"fs": {},
"rules": [{"name": "replicated_rule", "id": 0}],
"crush": "device 0 osd.0 class hdd\n",
"running": True,
}
async def _load_cluster_ceph(request: Request) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
@@ -60,14 +29,9 @@ async def _load_cluster_ceph(request: Request) -> dict[str, Any]:
)
metadata = state(row["metadata"]) if row is not None else {}
ceph = metadata.get("ceph")
if not isinstance(ceph, dict) or not ceph:
return dict(DEFAULT_CLUSTER_CEPH)
merged = dict(DEFAULT_CLUSTER_CEPH)
merged.update(ceph)
for key in ("config", "pools", "fs", "cfg_values"):
if not isinstance(merged.get(key), dict):
merged[key] = dict(cast(dict[str, Any], DEFAULT_CLUSTER_CEPH[key]))
return merged
if not isinstance(ceph, dict):
return {}
return dict(ceph)
async def _save_cluster_ceph(request: Request, ceph: dict[str, Any]) -> None:
@@ -82,34 +46,27 @@ async def _save_cluster_ceph(request: Request, ceph: dict[str, Any]) -> None:
async def _load_node_ceph(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ceph = ops.setdefault(
"ceph",
{
"mds": {},
"mgr": {},
"mon": {f"{node}": {"name": node, "addr": f"{node}.local:6789", "rank": 0}},
"log": [{"t": 1_700_000_000, "n": 0, "line": "ceph simulator ready"}],
},
)
ops = metadata.get("ops")
if not isinstance(ops, dict):
return {"mds": {}, "mgr": {}, "mon": {}, "log": []}
ceph = ops.get("ceph")
if not isinstance(ceph, dict):
ceph = {
"mds": {},
"mgr": {},
"mon": {},
"log": [],
}
ops["ceph"] = ceph
ceph.setdefault("mds", {})
ceph.setdefault("mgr", {})
ceph.setdefault("mon", {})
ceph.setdefault("log", [])
return ceph
return {"mds": {}, "mgr": {}, "mon": {}, "log": []}
return {
"mds": ceph.get("mds") if isinstance(ceph.get("mds"), dict) else {},
"mgr": ceph.get("mgr") if isinstance(ceph.get("mgr"), dict) else {},
"mon": ceph.get("mon") if isinstance(ceph.get("mon"), dict) else {},
"log": list(ceph.get("log") or []) if isinstance(ceph.get("log"), list) else [],
**{key: value for key, value in ceph.items() if key not in {"mds", "mgr", "mon", "log"}},
}
async def _save_node_ceph(request: Request, node: str, ceph: dict[str, Any]) -> None:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ops = metadata.get("ops")
if not isinstance(ops, dict):
ops = {}
metadata["ops"] = ops
ops["ceph"] = ceph
await save_node_metadata(request, node, metadata)
@@ -175,10 +132,12 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await require_node(request, str(payload["node"]))
ceph = await _load_cluster_ceph(request)
keys = [item.strip() for item in str(payload.get("config-keys") or "").split(",") if item]
stored = ceph.setdefault("cfg_values", {})
result = {key: stored.get(key, "") for key in keys} if keys else dict(stored)
await _save_cluster_ceph(request, ceph)
return result
stored = ceph.get("cfg_values")
if not isinstance(stored, dict):
stored = {}
if keys:
return {key: stored.get(key, "") for key in keys}
return dict(stored)
async def crush(request: Request, inputs: dict[str, Any]) -> str:
await require_node(request, str(values(inputs)["node"]))
@@ -202,12 +161,13 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
async def cmd_safety(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
return {
"safe": 1,
"action": payload.get("action"),
"service": payload.get("service"),
"id": payload.get("id"),
}
ceph = await _load_cluster_ceph(request)
safety = ceph.get("cmd_safety")
result = dict(safety) if isinstance(safety, dict) else {}
for key in ("action", "service", "id"):
if key in payload:
result[key] = payload[key]
return result
async def init(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
@@ -368,13 +328,7 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist")
return {
**pool,
"pg_num": pool.get("pg_num", 128),
"bytes_used": 0,
"percent_used": 0.0,
"healthy": True,
}
return dict(pool)
async def fs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
@@ -682,13 +636,16 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
metadata = current.get("metadata")
if isinstance(metadata, dict):
return dict(metadata)
return {
"osd": {
"id": int(osdid) if osdid.isdigit() else osdid,
"uuid": current.get("uuid") or f"osd-uuid-{osdid}",
"device_class": current.get("device_class", "hdd"),
"uuid": current.get("uuid", ""),
"device_class": current.get("device_class", ""),
},
"devices": [{"dev": current.get("dev") or f"/dev/sd{osdid}"}],
"devices": [{"dev": current.get("dev", "")}] if current.get("dev") else [],
}
async def cluster_ceph_status(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
@@ -698,17 +655,35 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
)
total = int(row["capacity_bytes"] or 0) if row is not None else 0
used = int(row["used_bytes"] or 0) if row is not None else 0
osd_count = await database(_request).pool.fetchval(
"SELECT count(*)::int FROM resources WHERE kind='ceph-osd'"
osd_rows = await database(_request).pool.fetch(
"SELECT state FROM resources WHERE kind='ceph-osd'"
)
num_osds = len(osd_rows)
num_up = 0
num_in = 0
for osd_row in osd_rows:
osd_state = state(osd_row["state"])
if str(osd_state.get("status") or "") == "up":
num_up += 1
if osd_state.get("in") in (True, 1, "1"):
num_in += 1
ceph = await _load_cluster_ceph(_request)
version = ceph.get("version")
version_str = (
str(version.get("str"))
if isinstance(version, dict) and version.get("str") is not None
else str(version or "")
)
health = ceph.get("health")
if not isinstance(health, dict):
health = {"status": "HEALTH_OK" if ceph.get("running") else "HEALTH_WARN"}
return {
"version": "17.2.7",
"health": {"status": "HEALTH_OK" if ceph.get("running", True) else "HEALTH_WARN"},
"version": version_str,
"health": health,
"osdmap": {
"num_osds": osd_count,
"num_up_osds": osd_count - 1,
"num_in_osds": osd_count - 1,
"num_osds": num_osds,
"num_up_osds": num_up,
"num_in_osds": num_in,
},
"pgmap": {"bytes_used": used, "bytes_total": total},
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())},
+17 -16
View File
@@ -46,24 +46,31 @@ def register_cluster_handlers(registry: HandlerRegistry) -> None:
"tasks",
)
async def cluster_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
async def cluster_status(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
from app.handlers.nodes import load_node_ops
rows = await database(request).pool.fetch(
"""SELECT id, name, status FROM nodes ORDER BY name"""
)
metadata = await cluster_metadata(request)
quorate = metadata.get("quorate")
result: list[dict[str, Any]] = []
for index, row in enumerate(rows):
online = str(row["status"]) == "online"
ops = await load_node_ops(request, str(row["name"]))
cluster_node = ops.get("cluster_status")
entry = dict(cluster_node) if isinstance(cluster_node, dict) else {}
result.append(
{
"id": str(row["id"]),
"name": str(row["name"]),
"nodeid": index,
"nodeid": entry.get("nodeid", index),
"online": 1 if online else 0,
"local": 1 if index == 0 else 0,
"ip": f"10.32.{index // 254 + 1}.{index % 254 + 10}",
"level": "c",
"type": "node",
"quorate": 1,
"local": entry.get("local", 1 if index == 0 else 0),
"ip": entry.get("ip") or ops.get("ip") or "",
"level": entry.get("level", "c"),
"type": entry.get("type", "node"),
"quorate": entry.get("quorate", quorate if quorate is not None else 1),
}
)
return result
@@ -95,14 +102,8 @@ def register_cluster_handlers(registry: HandlerRegistry) -> None:
metadata = state(row["metadata"]) if row is not None else {}
options = metadata.get("options", {})
if not isinstance(options, dict):
options = {}
return {
"keyboard": options.get("keyboard", "en-us"),
"email_from": options.get("email_from", "root@localhost"),
"http_proxy": options.get("http_proxy", ""),
"description": options.get("description", "Proxmox API emulator cluster"),
**options,
}
return {}
return dict(options)
async def cluster_options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
current = await cluster_options_get(request, inputs)
+36 -39
View File
@@ -19,30 +19,8 @@ from app.handlers.common import (
def _config(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"cluster_config",
{
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
},
)
if not isinstance(current, dict):
current = {
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
}
metadata["cluster_config"] = current
return current
current = metadata.get("cluster_config")
return current if isinstance(current, dict) else {}
def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
@@ -52,10 +30,12 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
config = dict(_config(metadata))
if payload.get("clustername"):
config["clustername"] = str(payload["clustername"])
config.setdefault("totem", {})["cluster_name"] = str(payload["clustername"])
totem = dict(config.get("totem") or {})
totem["cluster_name"] = str(payload["clustername"])
config["totem"] = totem
if "votes" in payload:
config["votes"] = payload["votes"]
if "nodeid" in payload:
@@ -64,6 +44,7 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
if links:
config["links"] = links
config["token"] = secrets.token_hex(16)
metadata["cluster_config"] = config
await save_cluster_metadata(request, metadata)
await database(request).pool.execute(
"""UPDATE clusters
@@ -97,11 +78,11 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def join_post(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
config = dict(_config(metadata))
hostname = str(payload.get("hostname") or payload.get("node") or "")
if not hostname:
raise ApiError(400, "parameter verification failed - 'hostname' missing")
joins = config.setdefault("join_info", {})
joins = dict(config.get("join_info") or {})
joins[hostname] = {
"hostname": hostname,
"fingerprint": payload.get("fingerprint"),
@@ -112,6 +93,8 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
# password accepted but not stored in clear form
if payload.get("password"):
joins[hostname]["password_set"] = True
config["join_info"] = joins
metadata["cluster_config"] = config
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
hostname,
@@ -128,14 +111,19 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
metadata = await cluster_metadata(request)
config = _config(metadata)
added_raw = config.get("added_nodes")
added: dict[str, Any] = dict(added_raw) if isinstance(added_raw, dict) else {}
result = []
for index, row in enumerate(rows, start=1):
name = str(row["name"])
entry_raw = added.get(name)
entry: dict[str, Any] = dict(entry_raw) if isinstance(entry_raw, dict) else {}
result.append(
{
"node": str(row["name"]),
"nodeid": index,
"ring0_addr": f"{row['name']}.local",
"quorum_votes": config.get("votes", 1),
"node": name,
"nodeid": entry.get("nodeid", index),
"ring0_addr": entry.get("ring0_addr", ""),
"quorum_votes": entry.get("quorum_votes", config.get("votes", 0)),
}
)
return result
@@ -144,8 +132,8 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
node = str(payload["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
config = dict(_config(metadata))
added = dict(config.get("added_nodes") or {})
added[node] = {
"node": node,
"nodeid": payload.get("nodeid"),
@@ -153,10 +141,14 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
"votes": payload.get("votes", 1),
"apiversion": payload.get("apiversion"),
"force": payload.get("force"),
"ring0_addr": payload.get("ring0_addr") or f"{node}.local",
"quorum_votes": payload.get("quorum_votes", payload.get("votes", 1)),
}
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
added[node]["links"] = links
config["added_nodes"] = added
metadata["cluster_config"] = config
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
@@ -172,11 +164,14 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
config = dict(_config(metadata))
added = dict(config.get("added_nodes") or {})
added.pop(node, None)
joins = config.setdefault("join_info", {})
joins = dict(config.get("join_info") or {})
joins.pop(node, None)
config["added_nodes"] = added
config["join_info"] = joins
metadata["cluster_config"] = config
await save_cluster_metadata(request, metadata)
# Keep node row; mark offline to avoid cascading guest deletes.
await database(request).pool.execute(
@@ -186,11 +181,13 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def qdevice(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("qdevice") or {"status": "disabled"})
qdevice = _config(metadata).get("qdevice")
return dict(qdevice) if isinstance(qdevice, dict) else {}
async def totem(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("totem") or {})
totem = _config(metadata).get("totem")
return dict(totem) if isinstance(totem, dict) else {}
registry.register("/cluster/config", "GET", index)
registry.register("/cluster/config", "POST", create)
+60 -96
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import copy
import secrets
import time
from typing import Any
@@ -22,56 +21,20 @@ from app.handlers.common import (
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_CEPH_FLAGS: dict[str, int] = {
"nobackfill": 0,
"nodeep-scrub": 0,
"nodown": 0,
"noin": 0,
"noout": 0,
"norebalance": 0,
"norecover": 0,
"noscrub": 0,
"notieragent": 0,
"pause": 0,
}
DEFAULT_CPU_FLAGS: list[dict[str, Any]] = [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
def _jobs(metadata: dict[str, Any]) -> dict[str, Any]:
jobs = metadata.setdefault("jobs", {})
if not isinstance(jobs, dict):
jobs = {}
metadata["jobs"] = jobs
sync = jobs.setdefault("realm_sync", {})
if not isinstance(sync, dict):
sync = {}
jobs["realm_sync"] = sync
return jobs
jobs = metadata.get("jobs")
return jobs if isinstance(jobs, dict) else {}
def _metrics(metadata: dict[str, Any]) -> dict[str, Any]:
metrics = metadata.setdefault("metrics", {})
if not isinstance(metrics, dict):
metrics = {}
metadata["metrics"] = metrics
servers = metrics.setdefault("servers", {})
if not isinstance(servers, dict):
servers = {}
metrics["servers"] = servers
return metrics
metrics = metadata.get("metrics")
return metrics if isinstance(metrics, dict) else {}
def _cpu_models(metadata: dict[str, Any]) -> dict[str, Any]:
models = metadata.get("qemu_cpu_models")
if not isinstance(models, dict):
models = {}
metadata["qemu_cpu_models"] = models
return models
return models if isinstance(models, dict) else {}
def _ha_rules_store(metadata: dict[str, Any]) -> list[dict[str, Any]]:
@@ -84,12 +47,7 @@ def _ha_rules_store(metadata: dict[str, Any]) -> list[dict[str, Any]]:
]
if isinstance(rules, list):
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
return list(defaults)
return []
def _save_ha_rules(metadata: dict[str, Any], rules: list[dict[str, Any]]) -> None:
@@ -105,18 +63,7 @@ def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
def _ceph(metadata: dict[str, Any]) -> dict[str, Any]:
ceph = metadata.get("ceph")
if not isinstance(ceph, dict):
ceph = {}
flags = ceph.get("flags")
if not isinstance(flags, dict):
flags = copy.deepcopy(DEFAULT_CEPH_FLAGS)
else:
merged = copy.deepcopy(DEFAULT_CEPH_FLAGS)
merged.update({str(key): int(value) for key, value in flags.items()})
flags = merged
ceph["flags"] = flags
metadata["ceph"] = ceph
return ceph
return ceph if isinstance(ceph, dict) else {}
async def _cluster_task(request: Request, *, task_type: str, worker: str) -> str:
@@ -174,8 +121,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
jobs = dict(_jobs(metadata))
sync = dict(jobs.get("realm_sync") or {})
if job_id in sync:
raise ApiError(409, "realm-sync job already exists")
entry = {
@@ -185,6 +132,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
entry.setdefault("enabled", 1)
entry.setdefault("realm", str(payload.get("realm") or "pam"))
sync[job_id] = entry
jobs["realm_sync"] = sync
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **entry}
@@ -193,8 +141,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
jobs = dict(_jobs(metadata))
sync = dict(jobs.get("realm_sync") or {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
updated = {
@@ -206,6 +154,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
},
}
sync[job_id] = updated
jobs["realm_sync"] = sync
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **updated}
@@ -213,23 +162,26 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def realm_sync_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
jobs = dict(_jobs(metadata))
sync = dict(jobs.get("realm_sync") or {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
del sync[job_id]
jobs["realm_sync"] = sync
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def schedule_analyze(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
schedule = str(values(inputs).get("schedule") or "*/15")
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
jobs = dict(_jobs(metadata))
results = jobs.get("schedule_analyze_results")
if not isinstance(results, list):
results = []
jobs["last_schedule_analyze"] = {"schedule": schedule, "at": int(time.time())}
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
now = int(time.time())
return [{"timestamp": now + offset * 900, "utc": True} for offset in range(4)]
return [dict(item) for item in results if isinstance(item, dict)]
async def metrics_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("export", "server")
@@ -238,8 +190,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return {
"data": metrics.get("export_data")
or '# HELP pve_up Node is up\npve_up{node="pve01"} 1\n',
"data": str(metrics.get("export_data") or ""),
"timestamp": int(time.time()),
}
@@ -248,9 +199,12 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.get("servers")
if not isinstance(servers, dict):
return []
return [
{"id": server_id, **dict(payload)}
for server_id, payload in sorted(metrics.get("servers", {}).items())
for server_id, payload in sorted(servers.items())
if isinstance(payload, dict)
]
@@ -267,8 +221,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
metrics = dict(_metrics(metadata))
servers = dict(metrics.get("servers") or {})
if server_id in servers:
raise ApiError(409, "metrics server already exists")
entry = {
@@ -279,6 +233,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
entry.setdefault("port", 8086)
entry.setdefault("enable", 1)
servers[server_id] = entry
metrics["servers"] = servers
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **entry}
@@ -287,8 +242,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
metrics = dict(_metrics(metadata))
servers = dict(metrics.get("servers") or {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
updated = {
@@ -300,6 +255,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
},
}
servers[server_id] = updated
metrics["servers"] = servers
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **updated}
@@ -307,19 +263,24 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def metrics_server_delete(request: Request, inputs: dict[str, Any]) -> None:
server_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
metrics = dict(_metrics(metadata))
servers = dict(metrics.get("servers") or {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
del servers[server_id]
metrics["servers"] = servers
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
async def qemu_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("cpu-flags", "custom-cpu-models")
async def qemu_cpu_flags(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_CPU_FLAGS)
async def qemu_cpu_flags(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
flags = metadata.get("qemu_cpu_flags")
if isinstance(flags, list):
return [dict(item) for item in flags if isinstance(item, dict)]
return []
async def cpu_models_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
@@ -440,14 +401,16 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> dict[str, int]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in ceph["flags"].items()}
flags = ceph.get("flags")
if not isinstance(flags, dict):
return {}
return {str(key): int(value) for key, value in flags.items()}
async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
payload = values(inputs)
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
ceph = dict(_ceph(metadata))
flags = dict(ceph.get("flags") or {})
for key, value in payload.items():
if key in {"delete", "digest"}:
continue
@@ -461,8 +424,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
flag = str(values(inputs)["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = ceph["flags"]
if flag not in flags:
flags = ceph.get("flags")
if not isinstance(flags, dict) or flag not in flags:
raise ApiError(404, "ceph flag does not exist")
return {flag: int(flags[flag])}
@@ -470,8 +433,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
flag = str(payload["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
ceph = dict(_ceph(metadata))
flags = dict(ceph.get("flags") or {})
if "value" in payload:
flags[flag] = int(payload["value"])
elif flag in payload:
@@ -486,14 +449,15 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
config_raw = ceph.get("config")
config: dict[str, Any] = dict(config_raw) if isinstance(config_raw, dict) else {}
version = ceph.get("version")
flags = ceph.get("flags")
return {
"version": ceph.get("version") or {"str": "18.2.2", "parts": [18, 2, 2]},
"fsid": ceph.get("config", {}).get("fsid")
if isinstance(ceph.get("config"), dict)
else "pve-simulator-fsid",
"initialized": int(bool(ceph.get("initialized", True))),
"flags": ceph.get("flags", {}),
"version": dict(version) if isinstance(version, dict) else {},
"fsid": str(config.get("fsid") or ""),
"initialized": int(bool(ceph.get("initialized"))),
"flags": dict(flags) if isinstance(flags, dict) else {},
}
async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None:
@@ -588,7 +552,7 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
log = job.get("log")
if isinstance(log, list):
return [dict(item) for item in log if isinstance(item, dict)]
return [{"t": int(time.time()), "n": 0, "msg": f"replication idle for {job.get('id')}"}]
return []
async def node_replication_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job = await node_replication_get(request, inputs)
+197 -21
View File
@@ -68,44 +68,220 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
}
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
from app.handlers.nodes import load_node_ops
rows = await _database(request).pool.fetch(
"SELECT name AS node, status FROM nodes ORDER BY name"
)
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
result: list[dict[str, Any]] = []
for row in rows:
name = str(row["node"])
ops = await load_node_ops(request, name)
status_payload = ops.get("status")
status_dict = dict(status_payload) if isinstance(status_payload, dict) else {}
fingerprint = status_dict.get("ssl_fingerprint") or status_dict.get("fingerprint")
if fingerprint in (None, "", 0) or isinstance(fingerprint, dict | list):
fingerprint = ":".join(["00"] * 32)
def _as_float(value: object, default: float) -> float:
if isinstance(value, bool) or value is None or isinstance(value, dict | list):
return default
try:
return float(str(value))
except (TypeError, ValueError):
return default
def _as_int(value: object, default: int) -> int:
if isinstance(value, bool) or value is None or isinstance(value, dict | list):
return default
try:
return int(float(str(value)))
except (TypeError, ValueError):
return default
item: dict[str, Any] = {
"node": name,
"status": str(row["status"]),
"type": "node",
"ssl_fingerprint": str(fingerprint),
"cpu": _as_float(status_dict.get("cpu"), 0.0),
"maxcpu": _as_int(status_dict.get("maxcpu"), 4),
"mem": _as_int(status_dict.get("mem"), _as_int(status_dict.get("memory"), 0)),
"maxmem": _as_int(status_dict.get("maxmem"), 8 * 1024**3),
"uptime": _as_int(status_dict.get("uptime"), 0),
"level": str(status_dict.get("level") or ""),
}
result.append(item)
return result
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.nodes import load_node_ops
node = str(cast(dict[str, Any], inputs["values"])["node"])
row = await _database(request).pool.fetchrow(
"SELECT name, status FROM nodes WHERE name=$1", node
)
if row is None:
raise ApiError(404, "node does not exist")
ops = await load_node_ops(request, node)
status = ops.get("status")
payload = dict(status) if isinstance(status, dict) else {}
return {
"status": str(row["status"]),
"node": str(row["name"]),
"uptime": 0,
"cpu": 0.0,
"memory": {"used": 0, "total": 0},
**payload,
}
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
ORDER BY r.kind, r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
raw_state = row["state"]
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
result.append(
{
"type": str(row["type"]),
"id": f"{row['type']}/{row['external_id']}",
"node": str(row["node"]),
**state,
}
async def resources(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
"""Cluster-wide inventory in Proxmox ``/cluster/resources`` shape.
Do not dump guest ``state`` / config blobs: QEMU ``cpu`` is a model
string (e.g. ``qemu64``), while this endpoint's ``cpu`` is utilization
(float). Bridged clients (bpg / pulumi-proxmoxve) decode strictly.
"""
def _as_dict(raw: object) -> dict[str, Any]:
if isinstance(raw, str):
loaded = json.loads(raw)
return dict(loaded) if isinstance(loaded, dict) else {}
return dict(raw) if isinstance(raw, dict) else {}
def _num(value: object, default: float | int) -> float | int:
if isinstance(value, bool) or value is None or isinstance(value, dict | list):
return default
try:
if isinstance(default, float):
return float(str(value))
return int(float(str(value)))
except (TypeError, ValueError):
return default
def _memory_bytes(state: dict[str, Any]) -> int:
raw = state.get("maxmem", state.get("memory"))
if raw in (None, ""):
return 0
value = _num(raw, 0)
# QEMU config stores memory in MiB; cluster resources use bytes.
if isinstance(raw, str) or (isinstance(value, int) and 0 < value < 10_000_000):
return int(value) * 1024 * 1024
return int(value)
def _maxcpu(state: dict[str, Any]) -> int:
cores = int(_num(state.get("cores", state.get("cpus", 1)), 1))
sockets = int(_num(state.get("sockets", 1), 1))
return max(cores * sockets, 1)
def _cpu_util(state: dict[str, Any], *, running: bool) -> float:
if not running:
return 0.0
samples = state.get("rrddata")
if isinstance(samples, list) and samples:
last = samples[-1]
if isinstance(last, dict):
return float(_num(last.get("cpu"), 0.0))
return (
float(_num(state.get("cpu"), 0.0)) if not isinstance(state.get("cpu"), str) else 0.0
)
type_filter = cast(dict[str, Any], inputs.get("values") or {}).get("type")
result: list[dict[str, Any]] = []
if type_filter in (None, "node"):
node_rows = await _database(request).pool.fetch(
"SELECT name AS node, status FROM nodes ORDER BY name"
)
from app.handlers.nodes import load_node_ops
for row in node_rows:
name = str(row["node"])
ops = await load_node_ops(request, name)
status_payload = ops.get("status")
status_dict = dict(status_payload) if isinstance(status_payload, dict) else {}
result.append(
{
"type": "node",
"id": f"node/{name}",
"node": name,
"status": str(row["status"]),
"cpu": float(_num(status_dict.get("cpu"), 0.0)),
"maxcpu": int(_num(status_dict.get("maxcpu"), 4)),
"mem": int(
_num(status_dict.get("mem"), _num(status_dict.get("memory"), 0))
),
"maxmem": int(_num(status_dict.get("maxmem"), 8 * 1024**3)),
"uptime": int(_num(status_dict.get("uptime"), 0)),
"level": str(status_dict.get("level") or ""),
}
)
kind_filter: tuple[str, ...] | None
if type_filter == "vm":
kind_filter = ("qemu", "lxc")
elif type_filter == "storage":
kind_filter = ("storage",)
elif type_filter in (None,):
kind_filter = ("qemu", "lxc", "storage")
elif type_filter in {"qemu", "lxc", "storage", "pool", "sdn"}:
kind_filter = (str(type_filter),)
else:
kind_filter = ()
if kind_filter:
rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind = ANY($1::text[])
ORDER BY r.kind, r.external_id""",
list(kind_filter),
)
for row in rows:
kind = str(row["type"])
external_id = str(row["external_id"])
node = str(row["node"])
state = _as_dict(row["state"])
if kind in {"qemu", "lxc"}:
status = str(state.get("status") or "stopped")
running = status in {"running", "paused"}
vmid = int(external_id)
item: dict[str, Any] = {
"type": kind,
"id": f"{kind}/{external_id}",
"node": node,
"vmid": vmid,
"name": str(state.get("name") or f"{kind}-{external_id}"),
"status": status,
"template": 1 if state.get("template") in {True, "1"} else 0,
"cpu": _cpu_util(state, running=running),
"maxcpu": _maxcpu(state),
"mem": int(_num(state.get("mem"), 0)) if running else 0,
"maxmem": _memory_bytes(state),
"disk": int(_num(state.get("disk"), 0)),
"maxdisk": int(_num(state.get("maxdisk"), 0)),
"uptime": int(_num(state.get("uptime"), 0)) if running else 0,
}
result.append(item)
elif kind == "storage":
content = state.get("content")
if isinstance(content, list):
content_text = ",".join(str(item) for item in content)
else:
content_text = str(content or "")
result.append(
{
"type": "storage",
"id": f"storage/{node}/{external_id}",
"node": node,
"storage": external_id,
"status": str(state.get("status") or "available"),
"content": content_text,
"disk": int(_num(state.get("disk"), 0)),
"maxdisk": int(_num(state.get("maxdisk"), 1 * 1024**3)),
"shared": int(_num(state.get("shared"), 0)),
"plugintype": str(
state.get("plugintype") or state.get("type") or "dir"
),
}
)
return result
async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
+72 -39
View File
@@ -13,20 +13,6 @@ from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, subdirs, values
from app.simulation.seed import CLUSTER_ID
DEFAULT_OPTIONS = {
"enable": 1,
"policy_in": "DROP",
"policy_out": "ACCEPT",
"log_level_in": "nolog",
"log_level_out": "nolog",
}
DEFAULT_MACROS = [
{"macro": "SSH", "descr": "Secure Shell"},
{"macro": "HTTPS", "descr": "Secure web server"},
{"macro": "HTTP", "descr": "Web server"},
]
ScopeFn = Callable[[dict[str, Any]], str]
@@ -50,19 +36,36 @@ async def _save_firewall(request: Request, firewall: dict[str, Any]) -> None:
)
def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
def _empty_scope() -> dict[str, Any]:
return {
"options": {},
"rules": [],
"aliases": {},
"ipset": {},
"groups": {},
"log": [],
}
def _get_scope(firewall: dict[str, Any], scope: str) -> dict[str, Any] | None:
scopes = firewall.get("scopes")
if not isinstance(scopes, dict):
return None
section = scopes.get(scope)
return cast(dict[str, Any], section) if isinstance(section, dict) else None
def _ensure_scope(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
"""Create an empty durable scope on mutation; never injects catalog defaults."""
scopes = firewall.setdefault("scopes", {})
if scope not in scopes or not isinstance(scopes[scope], dict):
scopes[scope] = {
"options": dict(DEFAULT_OPTIONS),
"rules": [],
"aliases": {},
"ipset": {},
"groups": {},
"log": [],
}
section = scopes[scope]
section.setdefault("options", dict(DEFAULT_OPTIONS))
if not isinstance(scopes, dict):
scopes = {}
firewall["scopes"] = scopes
section = scopes.get(scope)
if not isinstance(section, dict):
section = _empty_scope()
scopes[scope] = section
section.setdefault("options", {})
section.setdefault("rules", [])
section.setdefault("aliases", {})
section.setdefault("ipset", {})
@@ -71,6 +74,11 @@ def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
return cast(dict[str, Any], section)
def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
"""Mutation helper — ensure scope exists without template defaults."""
return _ensure_scope(firewall, scope)
def register_firewall_handlers(registry: HandlerRegistry) -> None:
def register_scope(
base: str,
@@ -98,13 +106,17 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
async def options_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
return dict(_scope_data(firewall, scope_fn(payload)).get("options", DEFAULT_OPTIONS))
section = _get_scope(firewall, scope_fn(payload))
if section is None:
return {}
options = section.get("options", {})
return dict(options) if isinstance(options, dict) else {}
async def options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
current = dict(section.get("options", DEFAULT_OPTIONS))
section = _ensure_scope(firewall, scope_fn(payload))
current = dict(section.get("options") or {})
for key, value in payload.items():
if key in {"node", "vmid", "delete", "digest"}:
continue
@@ -116,7 +128,10 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).get("rules", [])
section = _get_scope(firewall, scope_fn(payload))
if section is None:
return []
rules = section.get("rules", [])
return list(rules) if isinstance(rules, list) else []
async def rules_create(request: Request, inputs: dict[str, Any]) -> None:
@@ -169,7 +184,8 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
async def aliases_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).get("aliases", {})
section = _get_scope(firewall, scope_fn(payload))
aliases = section.get("aliases", {}) if section else {}
if not isinstance(aliases, dict):
return []
return [
@@ -236,7 +252,8 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
async def ipset_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).get("ipset", {})
section = _get_scope(firewall, scope_fn(payload))
ipsets = section.get("ipset", {}) if section else {}
if not isinstance(ipsets, dict):
return []
return [
@@ -352,27 +369,43 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
async def refs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
section = _get_scope(firewall, scope_fn(payload))
if section is None:
return []
refs: list[dict[str, Any]] = []
for name in section.get("aliases", {}):
for name in section.get("aliases", {}) or {}:
refs.append({"type": "alias", "name": name})
for name in section.get("ipset", {}):
for name in section.get("ipset", {}) or {}:
refs.append({"type": "ipset", "name": name})
return refs
async def log_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
log = _scope_data(firewall, scope_fn(payload)).get("log", [])
section = _get_scope(firewall, scope_fn(payload))
if section is None:
return []
log = section.get("log", [])
return list(log) if isinstance(log, list) else []
async def macros_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MACROS)
async def macros_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
from app.handlers.common import cluster_metadata
metadata = await cluster_metadata(request)
macros = metadata.get("firewall_macros")
if isinstance(macros, list):
return [dict(item) for item in macros if isinstance(item, dict)]
firewall = await _load_firewall(request)
nested = firewall.get("macros")
if isinstance(nested, list):
return [dict(item) for item in nested if isinstance(item, dict)]
return []
async def groups_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
section = _get_scope(firewall, scope_fn(payload))
groups = section.get("groups", {}) if section else {}
if not isinstance(groups, dict):
return []
return [
+19 -38
View File
@@ -142,7 +142,7 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
async def ha_groups(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
configured = _ha_groups(metadata)
result = [
return [
{
"group": group_id,
"nodes": str(payload.get("nodes", "")),
@@ -153,26 +153,6 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
}
for group_id, payload in sorted(configured.items())
]
if result:
return result
rows = await database(request).pool.fetch(
"""SELECT DISTINCT state->>'group' AS group_id
FROM resources WHERE kind='ha' AND state ? 'group'
ORDER BY 1"""
)
node_names = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
nodes = ",".join(str(row["name"]) for row in node_names) or "pve01"
return [
{
"group": str(row["group_id"]),
"nodes": nodes,
"nofailback": 0,
"restricted": 0,
"type": "group",
}
for row in rows
if row["group_id"]
]
async def ha_group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
group = str(values(inputs)["group"])
@@ -238,38 +218,39 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
)
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
ha_raw = metadata.get("ha")
ha: dict[str, Any] = dict(ha_raw) if isinstance(ha_raw, dict) else {}
status_raw = ha.get("status_current")
status: dict[str, Any] = dict(status_raw) if isinstance(status_raw, dict) else {}
armed = bool(ha.get("armed")) if "armed" in ha else False
return {
"quorate": 1,
"mode": "active" if ha.get("armed", True) else "disabled",
"master_node": str(master or "pve01"),
"quorate": status.get("quorate", metadata.get("quorate", 0)),
"mode": status.get("mode", "active" if armed else "disabled"),
"master_node": status.get("master_node", str(master or "")),
"ha_started": int(row["started"] or 0),
"ha_total": int(row["total"] or 0),
"armed": 1 if ha.get("armed", True) else 0,
"armed": 1 if armed else 0,
}
async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
armed = bool(ha.get("armed", True))
ha_raw = metadata.get("ha")
ha: dict[str, Any] = dict(ha_raw) if isinstance(ha_raw, dict) else {}
manager_raw = ha.get("manager_status")
manager: dict[str, Any] = dict(manager_raw) if isinstance(manager_raw, dict) else {}
armed = bool(ha.get("armed")) if "armed" in ha else False
return {
"manager_status": "active" if armed else "disabled",
"quorum": "OK",
"manager_status": manager.get("manager_status", "active" if armed else "disabled"),
"quorum": manager.get("quorum", ""),
"armed": 1 if armed else 0,
}
async def ha_rules(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
rules = metadata.get("ha_rules")
if isinstance(rules, list) and rules:
if isinstance(rules, list):
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
await save_cluster_metadata(request, metadata)
return list(defaults)
return []
async def ha_relocate(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
+8 -2
View File
@@ -323,7 +323,7 @@ def register_lxc_handlers(registry: HandlerRegistry) -> None:
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
@@ -333,7 +333,13 @@ def register_lxc_handlers(registry: HandlerRegistry) -> None:
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
state = _state(resource["state"])
pre = state.get("migrate_preconditions")
payload = dict(pre) if isinstance(pre, dict) else {}
payload["running"] = str(state.get("status") or "") == "running"
payload.setdefault("local_disks", [])
payload.setdefault("local_resources", [])
return payload
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
+6 -18
View File
@@ -39,12 +39,8 @@ def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
ifaces = state.setdefault(
"interfaces",
[{"name": "eth0", "hwaddr": "02:00:00:00:00:11", "inet": "192.0.2.20/24"}],
)
await _save_state(request, resource["id"], state)
return list(ifaces) if isinstance(ifaces, list) else []
ifaces = state.get("interfaces")
return [dict(item) for item in ifaces] if isinstance(ifaces, list) else []
async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
@@ -70,23 +66,15 @@ def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-ct-{values['vmid']}.rrd"})
await _save_state(request, resource["id"], state)
return dict(rrd_state)
rrd_state = state.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]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{"time": 1_700_000_000, "cpu": 0.02, "mem": 64 * 1024 * 1024},
{"time": 1_700_000_060, "cpu": 0.03, "mem": 66 * 1024 * 1024},
],
)
await _save_state(request, resource["id"], state)
return list(series)
series = state.get("rrddata")
return [dict(item) for item in series] if isinstance(series, list) else []
async def _console(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
+16 -9
View File
@@ -12,13 +12,14 @@ from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs
def _mappings(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault("mapping", {"dir": {}, "pci": {}, "usb": {}})
current = metadata.get("mapping")
if not isinstance(current, dict):
current = {"dir": {}, "pci": {}, "usb": {}}
metadata["mapping"] = current
for kind in ("dir", "pci", "usb"):
current.setdefault(kind, {})
return current
return {"dir": {}, "pci": {}, "usb": {}}
return {
"dir": dict(current["dir"]) if isinstance(current.get("dir"), dict) else {},
"pci": dict(current["pci"]) if isinstance(current.get("pci"), dict) else {},
"usb": dict(current["usb"]) if isinstance(current.get("usb"), dict) else {},
}
def register_mapping_handlers(registry: HandlerRegistry) -> None:
@@ -42,12 +43,14 @@ def register_mapping_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
mapping = _mappings(metadata)
store = mapping[kind]
if item_id in store:
raise ApiError(400, f"{kind} mapping '{item_id}' already exists")
store[item_id] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
metadata["mapping"] = mapping
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -62,7 +65,8 @@ def register_mapping_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
mapping = _mappings(metadata)
store = mapping[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
current = dict(store[item_id])
@@ -76,15 +80,18 @@ def register_mapping_handlers(registry: HandlerRegistry) -> None:
current[key] = value
current["id"] = item_id
store[item_id] = current
metadata["mapping"] = mapping
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
item_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
mapping = _mappings(metadata)
store = mapping[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
del store[item_id]
metadata["mapping"] = mapping
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_items)
+25 -110
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import copy
from typing import Any, cast
from fastapi import Request
@@ -20,92 +19,13 @@ from app.handlers.common import (
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_NODE_OPS: dict[str, Any] = {
"network": [
{
"iface": "vmbr0",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.0.0.10/24",
},
{
"iface": "vmbr1",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.10.0.10/24",
},
{"iface": "eno1", "type": "eth", "active": 1, "method": "manual"},
],
"disks": {
"list": [
{
"devpath": "/dev/sda",
"size": 1_000_000_000_000,
"model": "SIM-DISK-01",
"serial": "SIM0001",
"gpt": 1,
},
{
"devpath": "/dev/sdb",
"size": 2_000_000_000_000,
"model": "SIM-SSD-01",
"serial": "SIM0002",
"gpt": 0,
},
],
"directory": [],
"lvm": [],
"lvmthin": [],
"zfs": [],
"smart": {},
},
"services": {
"pveproxy": {"state": "running", "enabled": 1},
"pvedaemon": {"state": "running", "enabled": 1},
"pvestatd": {"state": "running", "enabled": 1},
"corosync": {"state": "running", "enabled": 1},
},
"apt": {
"packages": [
{
"Package": "pve-manager",
"Version": "9.2.3",
"OldVersion": "9.2.2",
"Status": "upgradable",
},
{"Package": "libpve-common-perl", "Version": "9.0.3", "Status": "installed"},
],
"repositories": [
{
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
"enabled": 1,
"types": "deb",
"uri": "http://download.proxmox.com/debian/pve",
"suites": "bookworm",
"components": "pve-no-subscription",
}
],
"update": {"status": "stopped", "exitstatus": "OK"},
"changelogs": {},
},
}
def default_node_ops() -> dict[str, Any]:
return copy.deepcopy(DEFAULT_NODE_OPS)
async def load_node_ops(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.get("ops")
if isinstance(ops, dict) and ops:
if isinstance(ops, dict):
return ops
ops = default_node_ops()
metadata["ops"] = ops
await save_node_metadata(request, node, metadata)
return ops
return {}
async def save_node_ops(request: Request, node: str, ops: dict[str, Any]) -> None:
@@ -135,19 +55,18 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
node = str(values(inputs)["node"])
name = str(values(inputs).get("name") or "pve-manager")
ops = await load_node_ops(request, node)
changelogs = ops.setdefault("apt", {}).setdefault("changelogs", {})
if name not in changelogs:
changelogs[name] = f"simulated changelog for {name}\n\n * emulator build\n"
await save_node_ops(request, node, ops)
return str(changelogs[name])
apt = ops.get("apt")
changelogs = apt.get("changelogs") if isinstance(apt, dict) else None
if not isinstance(changelogs, dict):
return ""
return str(changelogs.get(name) or "")
async def apt_update_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
update = ops.get("apt", {}).get("update", {"status": "stopped", "exitstatus": "OK"})
if isinstance(update, dict):
return dict(update)
return {"status": "stopped", "exitstatus": "OK"}
apt = ops.get("apt")
update = apt.get("update") if isinstance(apt, dict) else None
return dict(update) if isinstance(update, dict) else {}
async def apt_update_start(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
@@ -230,11 +149,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
async def _disks(request: Request, node: str) -> dict[str, Any]:
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
disks = ops.get("disks")
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
ops["disks"] = disks
await save_node_ops(request, node, ops)
return {}
return cast(dict[str, Any], disks)
async def disks_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
@@ -247,17 +164,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or "/dev/sda")
disks = await _disks(request, node)
smart = disks.setdefault("smart", {})
if disk not in smart:
smart[disk] = {
"health": "PASSED",
"type": "scsi",
"model": "SIM-DISK",
"serial": disk.rsplit("/", 1)[-1],
}
ops = await load_node_ops(request, node)
ops["disks"] = disks
await save_node_ops(request, node, ops)
smart = disks.get("smart")
if not isinstance(smart, dict) or disk not in smart:
return {}
return dict(smart[disk])
async def disks_collection(
@@ -286,7 +195,10 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
disks = ops.get("disks")
if not isinstance(disks, dict):
disks = {}
ops["disks"] = disks
items = list(disks.get("list") or [])
found = False
for item in items:
@@ -314,7 +226,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
disks = ops.get("disks")
if not isinstance(disks, dict):
raise ApiError(404, "disk does not exist")
items = list(disks.get("list") or [])
for item in items:
if item.get("devpath") == disk:
@@ -324,8 +238,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
else:
raise ApiError(404, "disk does not exist")
disks["list"] = items
smart = disks.setdefault("smart", {})
smart.pop(disk, None)
smart = disks.get("smart")
if isinstance(smart, dict):
smart.pop(disk, None)
ops["disks"] = disks
await save_node_ops(request, node, ops)
+105 -199
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import copy
import json
import secrets
import time
@@ -13,128 +12,49 @@ 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.handlers.nodes import 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": [],
},
)
certs = ops.get("certificates")
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 {"custom": None, "acme": {}, "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
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) or not scan:
scan = copy.deepcopy(DEFAULT_SCAN)
ops["scan"] = scan
for key, value in DEFAULT_SCAN.items():
scan.setdefault(key, copy.deepcopy(value))
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) or not subscription:
subscription = copy.deepcopy(DEFAULT_SUBSCRIPTION)
ops["subscription"] = subscription
if not isinstance(subscription, dict):
return {}
return subscription
def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]:
disks = ops.setdefault("disks", default_node_ops()["disks"])
disks = ops.get("disks")
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
disks = {}
ops["disks"] = disks
items = disks.setdefault(kind, [])
items = disks.get(kind)
if not isinstance(items, list):
items = []
disks[kind] = items
@@ -208,7 +128,11 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
}
entry["name"] = name
items.append(entry)
ops.setdefault("disks", default_node_ops()["disks"])[kind] = items
disks = ops.get("disks")
if not isinstance(disks, dict):
disks = {}
ops["disks"] = disks
disks[kind] = items
await save_node_ops(request, node, ops)
return entry
@@ -360,35 +284,40 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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},
]
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]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
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]]:
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"},
]
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]:
await require_node(request, str(values(inputs)["node"]))
return {"network": "", "type": "secure", "enabled": 1}
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"]))
@@ -399,8 +328,6 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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]:
@@ -430,8 +357,6 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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]:
@@ -452,7 +377,10 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
current = _subscription(ops)
method = request.method.upper()
if method == "DELETE":
ops["subscription"] = copy.deepcopy(DEFAULT_SUBSCRIPTION)
ops["subscription"] = {
"status": "notfound",
"message": "There is no subscription key",
}
await save_node_ops(request, node, ops)
return None
if method == "POST":
@@ -481,17 +409,8 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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]
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)
@@ -514,7 +433,10 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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"]))
apt = ops.get("apt")
if not isinstance(apt, dict):
apt = {}
ops["apt"] = apt
repositories = list(apt.get("repositories") or [])
method = request.method.upper()
if method == "POST":
@@ -561,18 +483,14 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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)
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 DEFAULT_CONFIG)
config = dict(ops.get("config") or {})
config.update(
{
key: value
@@ -589,18 +507,14 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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)
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 DEFAULT_DNS)
dns = dict(ops.get("dns") or {})
dns.update(
{
key: value
@@ -616,13 +530,10 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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)
current = dict(ops.get("time") or {})
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]:
@@ -630,7 +541,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = dict(ops.get("time") or DEFAULT_TIME)
current = dict(ops.get("time") or {})
if "timezone" in payload:
current["timezone"] = str(payload["timezone"])
now = int(time.time())
@@ -668,12 +579,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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": "", "digest": ""}
return {"data": str(hosts.get("data", "")), "digest": str(hosts.get("digest", ""))}
async def hosts_post(request: Request, inputs: dict[str, Any]) -> None:
@@ -692,46 +598,50 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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
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)
return [
{"n": index, "t": f"{node} kernel: simulated syslog line {index}"}
for index in range(limit)
]
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)
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"},
]
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)
return f"==== Proxmox node report for {node} ====\nuptime: simulated\n"
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]:
await require_node(_request, str(values(inputs)["node"]))
return {"filename": "/var/lib/rrdcached/db/pve-node.rrd"}
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]]:
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 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"])
@@ -809,7 +719,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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"] = []
ops["network_applied"] = False
await save_node_ops(request, node, ops)
@@ -818,28 +728,24 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
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]]
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.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])
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"])
+57 -50
View File
@@ -13,53 +13,40 @@ from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs
_SECRET_KEYS = frozenset({"token", "password", "secret"})
DEFAULT_MATCHER_FIELDS = [
{"name": "type", "type": "string"},
{"name": "hostname", "type": "string"},
{"name": "job-id", "type": "string"},
{"name": "severity", "type": "string"},
]
DEFAULT_MATCHER_FIELD_VALUES = [
{"field": "type", "value": "fencing"},
{"field": "type", "value": "package-updates"},
{"field": "type", "value": "replication"},
{"field": "type", "value": "system-mail"},
]
def _public(endpoint: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in endpoint.items() if key not in _SECRET_KEYS}
def _notifications(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"notifications",
{
"endpoints": {
"gotify": {},
"sendmail": {},
"smtp": {},
"webhook": {},
},
"matchers": {},
"tests": [],
},
)
current = metadata.get("notifications")
if not isinstance(current, dict):
current = {
return {
"endpoints": {"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
"matchers": {},
"tests": [],
}
metadata["notifications"] = current
current.setdefault(
"endpoints",
{"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
endpoints = current.get("endpoints")
if not isinstance(endpoints, dict):
endpoints = {}
result = {
key: value
for key, value in current.items()
if key not in {"endpoints", "matchers", "tests"}
}
result["endpoints"] = {
"gotify": dict(endpoints["gotify"]) if isinstance(endpoints.get("gotify"), dict) else {},
"sendmail": (
dict(endpoints["sendmail"]) if isinstance(endpoints.get("sendmail"), dict) else {}
),
"smtp": dict(endpoints["smtp"]) if isinstance(endpoints.get("smtp"), dict) else {},
"webhook": dict(endpoints["webhook"]) if isinstance(endpoints.get("webhook"), dict) else {},
}
result["matchers"] = (
dict(current["matchers"]) if isinstance(current.get("matchers"), dict) else {}
)
current.setdefault("matchers", {})
current.setdefault("tests", [])
return current
result["tests"] = list(current["tests"]) if isinstance(current.get("tests"), list) else []
return result
def register_notifications_handlers(registry: HandlerRegistry) -> None:
@@ -80,26 +67,28 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
async def list_endpoints(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
store = _notifications(metadata)["endpoints"].get(kind) or {}
return [_public({"name": name, **item}) for name, item in sorted(store.items())]
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
notifications = _notifications(metadata)
store = notifications["endpoints"].setdefault(kind, {})
if name in store:
raise ApiError(400, f"{kind} endpoint '{name}' already exists")
entry = {key: payload[key] for key in create_keys if key in payload}
entry["name"] = name
entry.setdefault("disable", 0)
store[name] = entry
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
store = _notifications(metadata)["endpoints"].get(kind) or {}
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
return _public({"name": name, **store[name]})
@@ -108,7 +97,8 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
notifications = _notifications(metadata)
store = notifications["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
current = dict(store[name])
@@ -123,15 +113,18 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
current[key] = value
current["name"] = name
store[name] = current
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
notifications = _notifications(metadata)
store = notifications["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
del store[name]
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_endpoints)
@@ -149,12 +142,14 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
notifications = _notifications(metadata)
store = notifications["matchers"]
if name in store:
raise ApiError(400, f"matcher '{name}' already exists")
store[name] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
async def matchers_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -169,7 +164,8 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
notifications = _notifications(metadata)
store = notifications["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
current = dict(store[name])
@@ -183,24 +179,35 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
current[key] = value
current["name"] = name
store[name] = current
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
async def matchers_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
notifications = _notifications(metadata)
store = notifications["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
del store[name]
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
async def matcher_fields(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELDS)
async def matcher_fields(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
fields = _notifications(metadata).get("matcher_fields")
if isinstance(fields, list):
return [dict(item) for item in fields if isinstance(item, dict)]
return []
async def matcher_field_values(
_request: Request, _inputs: dict[str, Any]
request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELD_VALUES)
metadata = await cluster_metadata(request)
values_list = _notifications(metadata).get("matcher_field_values")
if isinstance(values_list, list):
return [dict(item) for item in values_list if isinstance(item, dict)]
return []
async def targets(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
@@ -231,10 +238,10 @@ def register_notifications_handlers(registry: HandlerRegistry) -> None:
break
if not found:
raise ApiError(404, "notification target does not exist")
tests = notifications.setdefault("tests", [])
if not isinstance(tests, list):
tests = notifications["tests"] = []
tests = list(notifications.get("tests") or [])
tests.append({"name": name, "tested_at": int(time.time()), "ok": True})
notifications["tests"] = tests
metadata["notifications"] = notifications
await save_cluster_metadata(request, metadata)
registry.register("/cluster/notifications", "GET", index)
+2 -2
View File
@@ -10,7 +10,7 @@ from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, state, values
from app.handlers.common import database, require_value, state, values
from app.simulation.seed import CLUSTER_ID, stable_id
@@ -117,7 +117,7 @@ def register_pool_handlers(registry: HandlerRegistry) -> None:
)
async def pool_delete(request: Request, inputs: dict[str, Any]) -> None:
poolid = str(values(inputs)["poolid"])
poolid = str(require_value(values(inputs), "poolid"))
status = await database(request).pool.execute(
"DELETE FROM pools WHERE pool_id=$1",
poolid,
+76 -32
View File
@@ -33,6 +33,51 @@ def _state(value: object) -> dict[str, Any]:
return dict(cast(Mapping[str, Any], value))
# Runtime-only keys stored in ``resources.state``; never echo on GET /config.
_QEMU_CONFIG_INTERNAL = frozenset(
{
"agent",
"cloudinit_dump",
"config",
"interfaces",
"migrate_preconditions",
"rrd",
"rrddata",
"sendkey",
"status",
}
)
def _agent_config_string(value: object) -> str:
"""Proxmox QEMU config ``agent`` is a string (e.g. ``1`` / ``0``), not an object."""
if isinstance(value, dict):
return "1" if value.get("enabled", True) else "0"
if isinstance(value, bool):
return "1" if value else "0"
if value in (None, ""):
return "0"
text = str(value).strip()
return text or "0"
def _public_qemu_config(config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
"""Wire-format QEMU config for clients (bpg / pulumi-proxmoxve)."""
merged = {**state, **config}
payload: dict[str, Any] = {}
for key, value in merged.items():
if key in _QEMU_CONFIG_INTERNAL:
continue
if isinstance(value, dict | list):
continue
payload[key] = value
agent = config.get("agent", state.get("agent", "0"))
payload["agent"] = _agent_config_string(agent)
return payload
def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
@@ -117,7 +162,10 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
return {
"vmid": int(vmid),
**_public_qemu_config(_state(row["config"]), _state(row["state"])),
}
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
@@ -406,7 +454,7 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
@@ -416,7 +464,13 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
state = _state(resource["state"])
pre = state.get("migrate_preconditions")
payload = dict(pre) if isinstance(pre, dict) else {}
payload["running"] = str(state.get("status") or "") == "running"
payload.setdefault("local_disks", [])
payload.setdefault("local_resources", [])
return payload
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
@@ -544,39 +598,16 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
command: str, request: Request, inputs: dict[str, Any]
) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
config = _state(resource["config"])
vmid = str(_values(inputs)["vmid"])
results: dict[str, Any] = {
"info": {
"version": "9.2.0-simulator",
"supported_commands": [
{"name": name, "enabled": True, "success-response": True}
for name in ("guest-ping", "guest-info", "guest-get-osinfo")
],
},
"get-osinfo": {
"name": str(config.get("ostype", "linux")),
"pretty-name": "Proxmox Simulator Guest",
"version": "1.0",
"machine": "x86_64",
},
"get-host-name": {"host-name": str(config.get("name", f"vm-{vmid}"))},
"network-get-interfaces": [
{
"name": "eth0",
"hardware-address": "02:00:00:00:00:01",
"ip-addresses": [
{"ip-address": "192.0.2.10", "ip-address-type": "ipv4", "prefix": 24}
],
}
],
"ping": {},
}
if command == "get-time":
seconds = int(
await _database(request).pool.fetchval("SELECT extract(epoch from now())::bigint")
)
return {"result": {"seconds": seconds, "nanoseconds": 0}}
state = _state(resource["state"])
agent = state.get("agent")
results = agent.get("results") if isinstance(agent, dict) else None
if not isinstance(results, dict) or command not in results:
raise ApiError(404, f"agent command '{command}' has no stored result")
return {"result": results[command]}
async def agent_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -586,6 +617,12 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
return await agent_result("get-osinfo", request, inputs)
async def agent_hostname(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
config = _state(resource["config"])
name = state.get("name") or config.get("name")
if name:
return {"result": {"host-name": str(name)}}
return await agent_result("get-host-name", request, inputs)
async def agent_network(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -825,7 +862,14 @@ async def _agent_resource(request: Request, values: dict[str, Any]) -> Any:
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
state = _state(resource["state"])
if str(config.get("agent", "0")).split(",", 1)[0].lower() not in {"1", "true", "yes"}:
agent = state.get("agent")
if not isinstance(agent, dict):
agent = config.get("agent")
if isinstance(agent, dict):
enabled = bool(agent.get("enabled", True))
else:
enabled = str(config.get("agent", "0")).split(",", 1)[0].lower() in {"1", "true", "yes"}
if not enabled:
raise ApiError(409, "QEMU guest agent is not enabled")
if state.get("status") != "running":
raise ApiError(409, "QEMU guest agent is not running")
+23 -49
View File
@@ -79,20 +79,10 @@ def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
async def _agent_blob(command: str, request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
agent = state.setdefault("agent", {})
blobs = agent.setdefault("results", {})
defaults: dict[str, Any] = {
"get-users": [{"user": "root", "login-time": 0}],
"get-fsinfo": [{"name": "/", "type": "ext4", "total-bytes": 32 * 1024**3}],
"get-memory-block-info": {"size": 1024**3},
"get-memory-blocks": [{"start": 0, "size": 1024**3}],
"get-timezone": {"zone": "UTC", "offset": 0},
"get-vcpus": [{"online": True, "can-offline": False}],
"fsfreeze-status": "thawed",
}
if command not in blobs:
blobs[command] = defaults.get(command, {})
await _save_guest_state(request, resource["id"], state)
agent = state.get("agent")
blobs = agent.get("results") if isinstance(agent, dict) else None
if not isinstance(blobs, dict) or command not in blobs:
raise ApiError(404, f"agent command '{command}' has no stored result")
return {"result": blobs[command]}
async def agent_users(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -146,10 +136,10 @@ def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
resource = await _agent_resource(request, payload)
path = str(payload.get("file") or payload.get("path") or "/etc/hostname")
state = _state(resource["state"])
files = state.setdefault("agent", {}).setdefault("files", {})
if path not in files:
files[path] = f"simulated:{path}\n"
await _save_guest_state(request, resource["id"], state)
agent = state.get("agent")
files = agent.get("files") if isinstance(agent, dict) else None
if not isinstance(files, dict) or path not in files:
raise ApiError(404, "file does not exist")
content = str(files[path])
return {"result": {"content": content, "truncated": True}}
@@ -188,9 +178,15 @@ def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
async def agent_fstrim(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state.setdefault("agent", {})["last_fstrim"] = True
agent = dict(state.get("agent") or {})
agent["last_fstrim"] = True
state["agent"] = agent
await _save_guest_state(request, resource["id"], state)
return {"result": {"paths": [{"path": "/", "trimmed": 0}]}}
results = agent.get("results") if isinstance(agent.get("results"), dict) else {}
fstrim = results.get("fstrim") if isinstance(results, dict) else None
if isinstance(fstrim, dict):
return {"result": fstrim}
return {"result": {}}
async def agent_set_password(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
@@ -258,45 +254,23 @@ def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
async def cloudinit_dump(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
return (
f"#cloud-config\nhostname: {config.get('name', values['vmid'])}\n"
f"manage_etc_hosts: true\n"
)
state = _state(resource["state"])
dump = state.get("cloudinit_dump")
return str(dump) if dump is not None else ""
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-vm-{values['vmid']}.rrd"})
await _save_guest_state(request, resource["id"], state)
return dict(rrd_state)
rrd_state = state.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]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{
"time": 1_700_000_000,
"cpu": 0.05,
"mem": 256 * 1024 * 1024,
"netin": 0,
"netout": 0,
},
{
"time": 1_700_000_060,
"cpu": 0.08,
"mem": 260 * 1024 * 1024,
"netin": 100,
"netout": 80,
},
],
)
await _save_guest_state(request, resource["id"], state)
return list(series)
series = state.get("rrddata")
return [dict(item) for item in series] if isinstance(series, list) else []
async def monitor(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
+124 -120
View File
@@ -21,52 +21,8 @@ _SECRET_KEYS = frozenset({"key", "token", "fingerprint"})
def _sdn(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"sdn",
{
"zones": {},
"vnets": {},
"controllers": {},
"dns": {},
"ipams": {},
"fabrics": {},
"fabric_nodes": {},
"prefix_lists": {},
"route_maps": {},
"lock": None,
"pending": False,
"running_version": 1,
},
)
if not isinstance(current, dict):
current = {
"zones": {},
"vnets": {},
"controllers": {},
"dns": {},
"ipams": {},
"fabrics": {},
"fabric_nodes": {},
"prefix_lists": {},
"route_maps": {},
"lock": None,
"pending": False,
"running_version": 1,
}
metadata["sdn"] = current
for key in (
"zones",
"vnets",
"controllers",
"dns",
"ipams",
"fabrics",
"fabric_nodes",
"prefix_lists",
"route_maps",
):
current.setdefault(key, {})
return current
current = metadata.get("sdn")
return current if isinstance(current, dict) else {}
def _public(item: dict[str, Any]) -> dict[str, Any]:
@@ -77,9 +33,14 @@ def _store_list(store: dict[str, Any], *, id_key: str) -> list[dict[str, Any]]:
return [_public({id_key: name, **item}) for name, item in sorted(store.items())]
async def _load(request: Request) -> tuple[dict[str, Any], dict[str, Any]]:
async def _load(
request: Request, *, for_write: bool = False
) -> tuple[dict[str, Any], dict[str, Any]]:
metadata = await cluster_metadata(request)
return metadata, _sdn(metadata)
sdn = _sdn(metadata)
if for_write and metadata.get("sdn") is not sdn:
metadata["sdn"] = sdn
return metadata, sdn
def register_sdn_handlers(registry: HandlerRegistry) -> None:
@@ -100,7 +61,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def apply(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
lock = sdn.get("lock")
token = payload.get("lock-token")
if lock and token and lock.get("token") != token:
@@ -112,7 +73,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
await save_cluster_metadata(request, metadata)
async def lock_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
if sdn.get("lock") and not values(inputs).get("allow-pending"):
raise ApiError(400, "SDN is already locked")
token = secrets.token_hex(8)
@@ -122,7 +83,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def lock_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
lock = sdn.get("lock")
if lock is None:
return None
@@ -133,14 +94,14 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def rollback(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
sdn["pending"] = False
if payload.get("release-lock"):
sdn["lock"] = None
await save_cluster_metadata(request, metadata)
async def dry_run(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return [
{"type": "zone", "name": name, "action": "noop"}
for name in sorted(sdn.get("zones") or {})
@@ -154,7 +115,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
create_required: str | None = None,
) -> None:
async def list_items(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
items = _store_list(sdn.get(store_key) or {}, id_key=id_param)
type_filter = values(inputs).get("type")
if type_filter:
@@ -164,7 +125,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload[create_required or id_param])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault(store_key, {})
if item_id in store:
raise ApiError(400, f"{store_key} '{item_id}' already exists")
@@ -179,7 +140,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
item_id = str(values(inputs)[id_param])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get(store_key) or {}).get(item_id)
if not isinstance(item, dict):
raise ApiError(404, f"{store_key} entry does not exist")
@@ -188,7 +149,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload[id_param])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault(store_key, {})
if item_id not in store:
raise ApiError(404, f"{store_key} entry does not exist")
@@ -208,7 +169,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def delete(request: Request, inputs: dict[str, Any]) -> None:
item_id = str(values(inputs)[id_param])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault(store_key, {})
if item_id not in store:
raise ApiError(404, f"{store_key} entry does not exist")
@@ -230,20 +191,20 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def ipam_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
ipam = str(values(inputs)["ipam"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
if ipam not in (sdn.get("ipams") or {}):
raise ApiError(404, "ipam does not exist")
return {"status": "ok", "ipam": ipam}
# vnets + nested
async def vnets_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return _store_list(sdn.get("vnets") or {}, id_key="vnet")
async def vnets_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("vnets", {})
if vnet in store:
raise ApiError(400, f"vnet '{vnet}' already exists")
@@ -259,7 +220,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def vnet_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
vnet = str(values(inputs)["vnet"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("vnets") or {}).get(vnet)
if not isinstance(item, dict):
raise ApiError(404, "vnet does not exist")
@@ -268,7 +229,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def vnet_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("vnets", {})
if vnet not in store:
raise ApiError(404, "vnet does not exist")
@@ -288,7 +249,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def vnet_delete(request: Request, inputs: dict[str, Any]) -> None:
vnet = str(values(inputs)["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("vnets", {})
if vnet not in store:
raise ApiError(404, "vnet does not exist")
@@ -307,7 +268,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def subnets_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
vnet = str(values(inputs)["vnet"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
return _store_list(item.get("subnets") or {}, id_key="subnet")
@@ -315,7 +276,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
subnet = str(payload["subnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
subnets = item.setdefault("subnets", {})
if subnet in subnets:
@@ -331,7 +292,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
subnet = str(payload["subnet"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
data = (item.get("subnets") or {}).get(subnet)
if not isinstance(data, dict):
@@ -342,7 +303,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
subnet = str(payload["subnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
subnets = item.setdefault("subnets", {})
if subnet not in subnets:
@@ -365,7 +326,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
subnet = str(payload["subnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
subnets = item.setdefault("subnets", {})
if subnet not in subnets:
@@ -377,7 +338,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def ips_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
ips = item.setdefault("ips", [])
if not isinstance(ips, list):
@@ -398,7 +359,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def ips_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
ips = item.setdefault("ips", [])
if not isinstance(ips, list):
@@ -416,14 +377,14 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fw_options_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
vnet = str(values(inputs)["vnet"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
return dict(item.get("firewall", {}).get("options") or {"enable": 0})
async def fw_options_put(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
options = dict(item.setdefault("firewall", {}).setdefault("options", {"enable": 0}))
for key, value in payload.items():
@@ -435,7 +396,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fw_rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
vnet = str(values(inputs)["vnet"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
rules = item.get("firewall", {}).get("rules") or []
return list(rules) if isinstance(rules, list) else []
@@ -443,7 +404,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fw_rules_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
rules = item.setdefault("firewall", {}).setdefault("rules", [])
if not isinstance(rules, list):
@@ -464,7 +425,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
pos = int(payload["pos"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
rules = item.setdefault("firewall", {}).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
@@ -479,7 +440,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
vnet = str(payload["vnet"])
pos = int(payload["pos"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = await _vnet(sdn, vnet)
rules = item.setdefault("firewall", {}).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
@@ -492,7 +453,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
return subdirs("all", "fabric", "node")
async def fabrics_all(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return _store_list(sdn.get("fabrics") or {}, id_key="id")
async def fabric_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
@@ -501,7 +462,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fabric_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
fabric_id = str(payload["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("fabrics", {})
if fabric_id in store:
raise ApiError(400, f"fabric '{fabric_id}' already exists")
@@ -514,7 +475,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fabric_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
fabric_id = str(values(inputs)["id"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("fabrics") or {}).get(fabric_id)
if not isinstance(item, dict):
raise ApiError(404, "fabric does not exist")
@@ -523,7 +484,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fabric_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
fabric_id = str(payload["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("fabrics", {})
if fabric_id not in store:
raise ApiError(404, "fabric does not exist")
@@ -543,7 +504,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def fabric_delete(request: Request, inputs: dict[str, Any]) -> None:
fabric_id = str(values(inputs)["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("fabrics", {})
if fabric_id not in store:
raise ApiError(404, "fabric does not exist")
@@ -554,7 +515,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
await save_cluster_metadata(request, metadata)
async def fabric_nodes_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
fabric_id = values(inputs).get("fabric_id")
nodes = sdn.get("fabric_nodes") or {}
result: list[dict[str, Any]] = []
@@ -571,7 +532,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
fabric_id = str(payload["fabric_id"])
node_id = str(payload["node_id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
if fabric_id not in (sdn.get("fabrics") or {}):
raise ApiError(404, "fabric does not exist")
store = sdn.setdefault("fabric_nodes", {}).setdefault(fabric_id, {})
@@ -588,7 +549,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
fabric_id = str(payload["fabric_id"])
node_id = str(payload["node_id"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = ((sdn.get("fabric_nodes") or {}).get(fabric_id) or {}).get(node_id)
if not isinstance(item, dict):
raise ApiError(404, "fabric node does not exist")
@@ -598,7 +559,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
fabric_id = str(payload["fabric_id"])
node_id = str(payload["node_id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("fabric_nodes", {}).setdefault(fabric_id, {})
if node_id not in store:
raise ApiError(404, "fabric node does not exist")
@@ -620,7 +581,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
fabric_id = str(payload["fabric_id"])
node_id = str(payload["node_id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("fabric_nodes", {}).setdefault(fabric_id, {})
if node_id not in store:
raise ApiError(404, "fabric node does not exist")
@@ -630,13 +591,13 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
# prefix lists
async def prefix_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return _store_list(sdn.get("prefix_lists") or {}, id_key="id")
async def prefix_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
list_id = str(payload["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("prefix_lists", {})
if list_id in store:
raise ApiError(400, f"prefix-list '{list_id}' already exists")
@@ -654,7 +615,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def prefix_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
list_id = str(values(inputs)["id"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -663,7 +624,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def prefix_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
list_id = str(payload["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("prefix_lists", {})
if list_id not in store:
raise ApiError(404, "prefix-list does not exist")
@@ -676,7 +637,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def prefix_delete(request: Request, inputs: dict[str, Any]) -> None:
list_id = str(values(inputs)["id"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
store = sdn.setdefault("prefix_lists", {})
if list_id not in store:
raise ApiError(404, "prefix-list does not exist")
@@ -686,7 +647,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def prefix_entries(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
list_id = str(values(inputs)["id"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -699,7 +660,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
list_id = str(payload["id"])
seq = str(payload.get("seq") or secrets.randbelow(10000))
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -720,7 +681,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
list_id = str(payload["id"])
seq = str(payload["url_seq"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -733,7 +694,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
list_id = str(payload["id"])
seq = str(payload["url_seq"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -752,7 +713,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
list_id = str(payload["id"])
seq = str(payload["url_seq"])
metadata, sdn = await _load(request)
metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("prefix_lists") or {}).get(list_id)
if not isinstance(item, dict):
raise ApiError(404, "prefix-list does not exist")
@@ -767,16 +728,44 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def route_maps_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("entries")
def _route_map_entries(item: object) -> dict[str, Any]:
if not isinstance(item, dict):
return {}
nested = item.get("entries")
if isinstance(nested, dict):
return {
str(key): value
for key, value in nested.items()
if isinstance(value, dict) and str(key).isdigit()
}
return {
str(key): value
for key, value in item.items()
if isinstance(value, dict) and str(key).isdigit()
}
def _route_map_entries_mutable(sdn: dict[str, Any], map_id: str) -> dict[str, Any]:
bucket = sdn.setdefault("route_maps", {}).setdefault(map_id, {})
if not isinstance(bucket, dict):
bucket = {}
sdn["route_maps"][map_id] = bucket
if "entries" in bucket or "name" in bucket:
entries = bucket.setdefault("entries", {})
if not isinstance(entries, dict):
entries = {}
bucket["entries"] = entries
return entries
return bucket
async def route_entries_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
maps = sdn.get("route_maps") or {}
route_map_id = values(inputs).get("route-map-id")
result: list[dict[str, Any]] = []
for map_id, entries in sorted(maps.items()):
for map_id, raw_entries in sorted(maps.items()):
if route_map_id and map_id != route_map_id:
continue
if not isinstance(entries, dict):
continue
entries = _route_map_entries(raw_entries)
for order, entry in sorted(entries.items(), key=lambda pair: int(pair[0])):
result.append({"route-map-id": map_id, "order": int(order), **entry})
return result
@@ -785,8 +774,8 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
map_id = str(payload["route-map-id"])
order = str(payload.get("order") or 10)
metadata, sdn = await _load(request)
entries = sdn.setdefault("route_maps", {}).setdefault(map_id, {})
metadata, sdn = await _load(request, for_write=True)
entries = _route_map_entries_mutable(sdn, map_id)
if order in entries:
raise ApiError(400, f"route-map entry '{order}' already exists")
entries[order] = {
@@ -809,8 +798,8 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
map_id = str(payload["route-map-id"])
order = str(payload["order"])
_metadata, sdn = await _load(request)
entry = ((sdn.get("route_maps") or {}).get(map_id) or {}).get(order)
_metadata, sdn = await _load(request, for_write=True)
entry = _route_map_entries((sdn.get("route_maps") or {}).get(map_id)).get(order)
if not isinstance(entry, dict):
raise ApiError(404, "route-map entry does not exist")
return {"route-map-id": map_id, "order": int(order), **entry}
@@ -819,8 +808,8 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
map_id = str(payload["route-map-id"])
order = str(payload["order"])
metadata, sdn = await _load(request)
entries = sdn.setdefault("route_maps", {}).setdefault(map_id, {})
metadata, sdn = await _load(request, for_write=True)
entries = _route_map_entries_mutable(sdn, map_id)
if order not in entries:
raise ApiError(404, "route-map entry does not exist")
current = dict(entries[order])
@@ -837,8 +826,8 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
map_id = str(payload["route-map-id"])
order = str(payload["order"])
metadata, sdn = await _load(request)
entries = sdn.setdefault("route_maps", {}).setdefault(map_id, {})
metadata, sdn = await _load(request, for_write=True)
entries = _route_map_entries_mutable(sdn, map_id)
if order not in entries:
raise ApiError(404, "route-map entry does not exist")
del entries[order]
@@ -852,13 +841,13 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def node_zones(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return _store_list(sdn.get("zones") or {}, id_key="zone")
async def node_zone(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(request, str(values(inputs)["node"]))
zone = str(values(inputs)["zone"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
item = (sdn.get("zones") or {}).get(zone)
if not isinstance(item, dict):
raise ApiError(404, "zone does not exist")
@@ -866,13 +855,16 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def node_zone_bridges(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
zone = await node_zone(request, inputs)
bridge = zone.get("bridge") or f"vmbr-{zone.get('zone')}"
return [{"iface": bridge, "active": 1}]
bridges = zone.get("bridges")
if isinstance(bridges, list):
return [dict(item) for item in bridges if isinstance(item, dict)]
bridge = zone.get("bridge")
return [{"iface": bridge, "active": 1}] if bridge else []
async def node_zone_content(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
zone = str(values(inputs)["zone"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
return [
{"vnet": name, **item}
for name, item in sorted((sdn.get("vnets") or {}).items())
@@ -881,7 +873,11 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def node_zone_ip_vrf(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
zone = await node_zone(request, inputs)
return {"zone": zone.get("zone"), "vrf": f"vrf-{zone.get('zone')}", "table": 100}
return {
"zone": zone.get("zone"),
"vrf": zone.get("vrf") or "",
"table": zone.get("table") if zone.get("table") is not None else 0,
}
async def node_vnet(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(request, str(values(inputs)["node"]))
@@ -889,7 +885,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
async def node_vnet_mac_vrf(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
vnet = await node_vnet(request, inputs)
return {"vnet": vnet.get("vnet"), "mac-vrf": f"macvrf-{vnet.get('vnet')}"}
return {"vnet": vnet.get("vnet"), "mac-vrf": vnet.get("mac-vrf") or ""}
async def node_fabric(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(request, str(values(inputs)["node"]))
@@ -901,7 +897,7 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
fabric = str(values(inputs)["fabric"])
_metadata, sdn = await _load(request)
_metadata, sdn = await _load(request, for_write=True)
nodes = (sdn.get("fabric_nodes") or {}).get(fabric) or {}
result = []
for node_id, item in nodes.items():
@@ -919,15 +915,23 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
fabric = str(values(inputs)["fabric"])
_metadata, sdn = await _load(request)
nodes = (sdn.get("fabric_nodes") or {}).get(fabric) or {}
return [{"node": node_id, "state": "up"} for node_id in sorted(nodes)]
return [
{
"node": node_id,
"state": item.get("state") if isinstance(item, dict) else "",
}
for node_id, item in sorted(nodes.items())
]
async def node_fabric_routes(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
fabric = str(values(inputs)["fabric"])
_metadata, sdn = await _load(request)
item = (sdn.get("fabrics") or {}).get(fabric) or {}
prefix = item.get("ip_prefix") or "10.0.0.0/24"
return [{"dst": prefix, "protocol": item.get("protocol") or "ospf"}]
routes = item.get("routes") if isinstance(item, dict) else None
if isinstance(routes, list):
return [dict(route) for route in routes if isinstance(route, dict)]
return []
# registrations
registry.register("/cluster/sdn", "GET", index)
+52 -22
View File
@@ -9,7 +9,15 @@ from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, storage_payload, subdirs, values
from app.handlers.common import (
database,
require_node,
require_value,
state,
storage_payload,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID, stable_id
@@ -485,18 +493,29 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
async def file_restore_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
row = await _storage_row(request, None, str(payload["storage"]))
filepath = str(payload.get("filepath") or "/")
return [{"filepath": filepath.rstrip("/") + "/etc", "type": "d", "text": "etc"}]
config = state(row["config"])
restore = config.get("file_restore")
if not isinstance(restore, dict):
return []
items = restore.get(filepath) or restore.get(filepath.rstrip("/") or "/")
return [dict(item) for item in items] if isinstance(items, list) else []
async def file_restore_download(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
row = await _storage_row(request, None, str(payload["storage"]))
config = state(row["config"])
downloads = config.get("file_restore_downloads")
filepath = str(payload.get("filepath") or "/")
if isinstance(downloads, dict) and filepath in downloads:
entry = downloads[filepath]
return dict(entry) if isinstance(entry, dict) else {"filepath": filepath}
return {
"download-url": f"/api2/json/nodes/{payload['node']}/storage/"
f"{payload['storage']}/file-restore/download",
"filepath": payload.get("filepath") or "/",
"filepath": filepath,
"volume": payload.get("volume"),
}
@@ -504,40 +523,51 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
payload = values(inputs)
await require_node(request, str(payload["node"]))
row = await _storage_row(request, None, str(payload["storage"]))
config = state(row["config"])
identity = config.get("identity")
if isinstance(identity, dict):
return {
"storage": str(row["storage_id"]),
"type": str(identity.get("type") or row["storage_type"]),
"fingerprint": str(identity.get("fingerprint") or ""),
}
return {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"fingerprint": f"sim-{row['storage_id']}",
"fingerprint": "",
}
async def import_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await _storage_row(request, None, storage_id)
return {
"type": "qemu",
"source": volume,
"disks": {"scsi0": f"{storage_id}:0/vm-import.raw"},
"net0": "virtio,bridge=vmbr0",
}
volume = str(require_value(payload, "volume"))
row = await _storage_row(request, None, storage_id)
config = state(row["config"])
by_volume = config.get("import_metadata_by_volume")
if isinstance(by_volume, dict) and volume in by_volume:
meta = by_volume[volume]
return dict(meta) if isinstance(meta, dict) else {}
meta = config.get("import_metadata")
if isinstance(meta, dict):
return {"source": volume, **meta}
return {}
async def storage_rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
await _storage_row(request, None, storage_id)
return {"filename": f"pve-storage-{storage_id}.rrd"}
row = await _storage_row(request, None, str(payload["storage"]))
config = state(row["config"])
rrd_state = config.get("rrd")
return dict(rrd_state) if isinstance(rrd_state, dict) else {}
async def storage_rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return [
{"time": 1_700_000_000, "used": 10, "total": 100},
{"time": 1_700_000_060, "used": 12, "total": 100},
]
row = await _storage_row(request, None, str(payload["storage"]))
config = state(row["config"])
series = config.get("rrddata")
return [dict(item) for item in series] if isinstance(series, list) else []
registry.register("/storage", "GET", storage_ids)
registry.register("/storage", "POST", storage_create)
+1127 -20
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -159,16 +159,16 @@ async def probe_major(
verb = method.verb.upper()
url = f"/api2/json{render_path(path_template)}"
headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
body = body_for(method, path_template) if verb in {"PUT", "POST"} else None
payload = body_for(method, path_template)
try:
if verb == "GET":
response = await client.get(url, headers=headers)
response = await client.get(url, headers=headers, params=payload or None)
elif verb == "PUT":
response = await client.put(url, data=body or {}, headers=headers)
response = await client.put(url, data=payload or {}, headers=headers)
elif verb == "POST":
response = await client.post(url, data=body or {}, headers=headers)
response = await client.post(url, data=payload or {}, headers=headers)
elif verb == "DELETE":
response = await client.delete(url, headers=headers)
response = await client.request("DELETE", url, data=payload or {}, headers=headers)
else:
continue
except Exception as exc:
+3 -1
View File
@@ -62,10 +62,12 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
async def _create(repository: TaskRepository, task: Task) -> dict[str, Any]:
from app.simulation.seed import enrich_guest_state
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
config = dict(task.payload.get("config", {}))
resource_id = uuid.uuid4()
state = {"status": "stopped", **config}
state = enrich_guest_state({"status": "stopped", **config}, kind="qemu", vmid=str(vmid))
async with repository.pool.acquire() as connection:
async with connection.transaction():
node_row = await connection.fetchrow(
+310 -30
View File
@@ -26,22 +26,22 @@
--muted: #5c6778;
--accent: #ff8500;
--accent-hover: #ff6a00;
--link: #1e88ff;
--link: #d97706;
--ok: #22c55e;
--ok-bg: #e6f8ed;
--warn: #9a6700;
--warn-bg: #fff8e6;
--err: #b42318;
--err-bg: #fdecea;
--info: #1e88ff;
--info-bg: #e8f2ff;
--info: #d97706;
--info-bg: #fff4e8;
--debug: #5c6778;
--debug-bg: #eef1f6;
--surface-raised: #fafbfc;
--surface-hover: #f3f5f8;
--surface-toolbar: #f6f8fb;
--surface-muted-hover: #eef1f6;
--surface-accent: #eef4ff;
--surface-accent: #fff4e8;
--surface-danger: #fffafa;
--border-strong: #c8d0db;
--brand-ink: #0f1419;
@@ -50,14 +50,14 @@
--brand-logo-bg: var(--overlay-head-bg);
--brand-logo-border: var(--border-strong);
--brand-subtle: #97a1b1;
--meta-desc-bg: #f4f7fb;
--meta-desc-border: #7eb6ff;
--meta-desc-text: #1e88ff;
--meta-desc-bg: #fff8f0;
--meta-desc-border: #ffb366;
--meta-desc-text: #c45a00;
--overlay-head-bg: #eef1f6;
--brand-accent: #ff8500;
--brand-accent-hover: #ff6a00;
--panel-accent: #22c55e;
--panel-accent-hover: #16a34a;
--panel-accent: #ff8500;
--panel-accent-hover: #ff6a00;
--mono: "SF Mono", "Cascadia Code", Consolas, monospace;
--sans: "Segoe UI", system-ui, sans-serif;
--header-h: 52px;
@@ -74,22 +74,22 @@
--muted: #b0bac8;
--accent: #ff8a1f;
--accent-hover: #ffaa55;
--link: #93c5fd;
--link: #ffb366;
--ok: #86efac;
--ok-bg: #1a3d28;
--warn: #f0c866;
--warn-bg: #3d3218;
--err: #fca5a5;
--err-bg: #3d2222;
--info: #93c5fd;
--info-bg: #1a2d4a;
--info: #ffb366;
--info-bg: #3d2a18;
--debug: #b0bac8;
--debug-bg: #283240;
--surface-raised: #242e3d;
--surface-hover: #2f3a4b;
--surface-toolbar: #151c27;
--surface-muted-hover: #2f3a4b;
--surface-accent: #1e3150;
--surface-accent: #3d2a18;
--surface-danger: #3a2626;
--brand-ink: #f2f5f9;
--brand-logo-core: #ffffff;
@@ -97,14 +97,14 @@
--brand-logo-bg: var(--overlay-head-bg);
--brand-logo-border: var(--border-strong);
--brand-subtle: #9aa8ba;
--meta-desc-bg: #1e2836;
--meta-desc-border: #3d5a85;
--meta-desc-text: #c8d8ef;
--meta-desc-bg: #2a2218;
--meta-desc-border: #8a5a28;
--meta-desc-text: #ffc078;
--overlay-head-bg: #242e3d;
--brand-accent: #ff8a1f;
--brand-accent-hover: #ffaa55;
--panel-accent: #4ade80;
--panel-accent-hover: #86efac;
--panel-accent: #ff8a1f;
--panel-accent-hover: #ffaa55;
}
html[data-theme="dark"] .header-tool-btn {
@@ -537,7 +537,7 @@
}
.header-tool-btn.data-badge-btn.loaded .data-badge-dot {
background: var(--ok);
background: var(--brand-accent);
}
.help-demo-panel {
@@ -1124,7 +1124,7 @@
grid-row: 1;
display: grid;
position: relative;
font-size: 15px;
font-size: 16.5px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
@@ -1158,18 +1158,22 @@
.workspace-brand-sub {
grid-row: 2;
display: block;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
font-size: 9.75px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.2;
font-size: 7.75px;
font-weight: 500;
letter-spacing: 0.14em;
line-height: 1.15;
text-transform: uppercase;
color: var(--muted);
text-align: left;
color: var(--brand-subtle);
white-space: nowrap;
text-align: justify;
text-align-last: justify;
word-spacing: 0;
}
html[data-theme="dark"] .workspace-brand-sub {
color: var(--border-strong);
}
.workspace-topbar-actions {
@@ -1769,6 +1773,7 @@
.data-drawer,
.url-drawer,
.endpoints-drawer,
.upid-drawer,
.help-drawer,
.ui-modal {
--accent: var(--panel-accent);
@@ -1783,6 +1788,7 @@
.data-drawer-head,
.url-drawer-head,
.endpoints-drawer-head,
.upid-drawer-head,
.help-drawer-head {
display: flex;
align-items: center;
@@ -1803,6 +1809,7 @@
.data-drawer-head h2,
.url-drawer-head h2,
.endpoints-drawer-head h2,
.upid-drawer-head h2,
.help-drawer-head h2 {
margin: 0;
font-size: 11px;
@@ -1828,6 +1835,7 @@
.catalog-drawer-head .icon-btn,
.environment-drawer-head .icon-btn,
.data-drawer-head .icon-btn,
.upid-drawer-head .icon-btn,
.url-drawer-head .icon-btn,
.endpoints-drawer-head .icon-btn,
.help-drawer-head .icon-btn {
@@ -1842,6 +1850,7 @@
.catalog-drawer-head .icon-btn:hover,
.environment-drawer-head .icon-btn:hover,
.data-drawer-head .icon-btn:hover,
.upid-drawer-head .icon-btn:hover,
.url-drawer-head .icon-btn:hover,
.endpoints-drawer-head .icon-btn:hover,
.help-drawer-head .icon-btn:hover {
@@ -1869,6 +1878,7 @@
.catalog-backdrop,
.environment-backdrop,
.data-backdrop,
.upid-backdrop,
.url-backdrop,
.endpoints-backdrop,
.help-backdrop {
@@ -1887,6 +1897,7 @@
.catalog-backdrop.open,
.environment-backdrop.open,
.data-backdrop.open,
.upid-backdrop.open,
.url-backdrop.open,
.endpoints-backdrop.open,
.help-backdrop.open {
@@ -1900,6 +1911,7 @@
.catalog-drawer,
.environment-drawer,
.data-drawer,
.upid-drawer,
.url-drawer,
.endpoints-drawer,
.help-drawer {
@@ -1926,6 +1938,7 @@
.catalog-drawer.open,
.environment-drawer.open,
.data-drawer.open,
.upid-drawer.open,
.url-drawer.open,
.endpoints-drawer.open,
.help-drawer.open {
@@ -1938,6 +1951,7 @@
.auth-drawer-body,
.environment-drawer-body,
.data-drawer-body,
.upid-drawer-body,
.url-drawer-body,
.endpoints-drawer-body,
.help-drawer-body {
@@ -1959,6 +1973,7 @@
.catalog-drawer-body .btn,
.environment-drawer-body .btn,
.data-drawer-body .btn,
.upid-drawer-body .btn,
.url-drawer-body .btn,
.endpoints-drawer-body .btn,
.help-drawer-body .btn,
@@ -1973,6 +1988,80 @@
line-height: 1.2;
}
.upid-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.upid-hint {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.upid-hint code {
font-family: var(--mono);
font-size: 11px;
}
.upid-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.upid-form .field label {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.upid-form .field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
}
.upid-form .field input:focus {
outline: none;
border-color: var(--brand-accent);
}
.upid-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.upid-output {
margin: 0;
min-height: 220px;
max-height: min(55vh, 520px);
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.help-drawer-body {
display: flex;
flex-direction: column;
@@ -3173,7 +3262,7 @@
<span class="workspace-brand-name-ghost" aria-hidden="true">PROXMOX</span>
<span class="workspace-brand-name-text"><span class="workspace-brand-core">PRO</span><span class="workspace-brand-x">X</span><span class="workspace-brand-core">MO</span><span class="workspace-brand-x">X</span></span>
</span>
<span class="workspace-brand-sub">API&nbsp;Simulator</span>
<span class="workspace-brand-sub">API Simulator</span>
</span>
</a>
<div class="workspace-topbar-actions">
@@ -3188,6 +3277,7 @@
<span>History</span>
<span class="history-badge-count" id="history-badge-count"></span>
</button>
<button type="button" class="header-tool-btn ok upid-badge-btn" id="upid-badge" aria-expanded="false" aria-controls="upid-drawer" data-tooltip="Poll task status and logs by UPID">UPID</button>
<button type="button" class="header-tool-btn ok data-badge-btn" id="data-badge" aria-expanded="false" aria-controls="data-drawer">
<span>Data</span>
<span class="data-badge-dot" aria-hidden="true"></span>
@@ -3431,6 +3521,42 @@
</div>
</aside>
<div class="upid-backdrop" id="upid-backdrop" aria-hidden="true"></div>
<aside class="upid-drawer" id="upid-drawer" aria-labelledby="upid-drawer-title" aria-hidden="true">
<div class="upid-drawer-head">
<h2 id="upid-drawer-title">UPID</h2>
<button class="icon-btn" id="upid-drawer-close" type="button" data-tooltip="Close panel" aria-label="Close panel">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"/>
</svg>
</button>
</div>
<div class="upid-drawer-body">
<div class="upid-panel">
<p class="upid-hint">
Async mutations return a Proxmox-style <strong>UPID</strong>. Poll
<code>/nodes/{node}/tasks/{upid}/status</code> until the task finishes, then inspect the log.
</p>
<div class="upid-form">
<div class="field">
<label for="upid-node">Node</label>
<input id="upid-node" value="pve01" spellcheck="false" autocomplete="off">
</div>
<div class="field">
<label for="upid-value">UPID</label>
<input id="upid-value" placeholder="UPID:pve01:… returned by a mutation" spellcheck="false" autocomplete="off">
</div>
<div class="upid-actions">
<button class="btn btn-primary btn-sm" id="btn-upid-status" type="button">Task status</button>
<button class="btn btn-sm" id="btn-upid-log" type="button">Task log</button>
<button class="btn btn-sm" id="btn-upid-from-response" type="button">From last response</button>
</div>
</div>
<pre class="upid-output" id="upid-output" aria-live="polite">Paste a UPID or send an async request, then poll status / log.</pre>
</div>
</div>
</aside>
<div class="url-backdrop" id="url-backdrop" aria-hidden="true"></div>
<aside class="url-drawer" id="url-drawer" aria-labelledby="url-drawer-title" aria-hidden="true">
<div class="url-drawer-head">
@@ -3699,6 +3825,7 @@
history: readStoredHistory(),
demoState: null,
lastResponseRaw: "",
lastUpid: null,
};
let methodDetailsRequestId = 0;
@@ -3742,6 +3869,16 @@
dataDrawer: document.getElementById("data-drawer"),
dataDrawerClose: document.getElementById("data-drawer-close"),
dataPanel: document.getElementById("data-panel"),
upidBadge: document.getElementById("upid-badge"),
upidBackdrop: document.getElementById("upid-backdrop"),
upidDrawer: document.getElementById("upid-drawer"),
upidDrawerClose: document.getElementById("upid-drawer-close"),
upidNode: document.getElementById("upid-node"),
upidValue: document.getElementById("upid-value"),
upidOutput: document.getElementById("upid-output"),
btnUpidStatus: document.getElementById("btn-upid-status"),
btnUpidLog: document.getElementById("btn-upid-log"),
btnUpidFromResponse: document.getElementById("btn-upid-from-response"),
catalogImplFill: document.getElementById("catalog-impl-fill"),
catalogImplValue: document.getElementById("catalog-impl-value"),
endpointsBadge: document.getElementById("endpoints-badge"),
@@ -5114,6 +5251,124 @@
toggleDataDrawer(false);
}
function toggleUpidDrawer(force) {
if (!els.upidDrawer || !els.upidBackdrop || !els.upidBadge) return;
const open = typeof force === "boolean" ? force : !els.upidDrawer.classList.contains("open");
els.upidDrawer.classList.toggle("open", open);
els.upidBackdrop.classList.toggle("open", open);
els.upidBadge.setAttribute("aria-expanded", open ? "true" : "false");
els.upidDrawer.setAttribute("aria-hidden", open ? "false" : "true");
els.upidBackdrop.setAttribute("aria-hidden", open ? "false" : "true");
if (open && state.lastUpid && els.upidValue && !els.upidValue.value.trim()) {
applyCapturedUpid(state.lastUpid);
}
}
function closeUpidDrawer() {
toggleUpidDrawer(false);
}
function parseUpidNode(upid) {
const match = String(upid || "").match(/^UPID:([^:]+):/i);
return match ? match[1] : null;
}
function extractUpidFromPayload(payload) {
if (typeof payload === "string" && /^UPID:/i.test(payload.trim())) {
return payload.trim();
}
if (!payload || typeof payload !== "object") return null;
if (typeof payload.data === "string" && /^UPID:/i.test(payload.data.trim())) {
return payload.data.trim();
}
if (typeof payload.upid === "string" && /^UPID:/i.test(payload.upid.trim())) {
return payload.upid.trim();
}
if (payload.data && typeof payload.data === "object") {
if (typeof payload.data.upid === "string" && /^UPID:/i.test(payload.data.upid.trim())) {
return payload.data.upid.trim();
}
}
return null;
}
function applyCapturedUpid(upid) {
if (!upid || !els.upidValue) return false;
els.upidValue.value = upid;
const node = parseUpidNode(upid);
if (node && els.upidNode) els.upidNode.value = node;
return true;
}
function captureUpidFromResult(result) {
const upid = extractUpidFromPayload(result?.body);
if (!upid) return;
state.lastUpid = upid;
applyCapturedUpid(upid);
if (els.upidBadge) {
els.upidBadge.classList.remove("warn");
els.upidBadge.classList.add("ok");
els.upidBadge.dataset.tooltip = `Last UPID captured · ${upid}`;
}
}
function showUpidOutput(value) {
if (!els.upidOutput) return;
if (value !== null && typeof value === "object") {
els.upidOutput.textContent = JSON.stringify(value, null, 2);
} else {
els.upidOutput.textContent = String(value ?? "");
}
}
async function fetchUpidTask(kind) {
const node = (els.upidNode?.value || "").trim();
const upid = (els.upidValue?.value || "").trim();
if (!node) {
showUpidOutput("Node is required.");
toast("Enter a node name", "warn");
return;
}
if (!upid) {
showUpidOutput("UPID is required.");
toast("Enter a UPID", "warn");
return;
}
if (!state.ticket) {
toast("Sign in first — task endpoints require authentication", "warn");
}
const suffix = kind === "log" ? "log" : "status";
const path = `/nodes/${encodeURIComponent(node)}/tasks/${encodeURIComponent(upid)}/${suffix}`;
setLoading(true);
try {
const result = await api("GET", path);
showUpidOutput({ status: result.status, data: result.body });
const toastLevel = result.status >= 500 ? "error" : result.status >= 400 ? "warn" : "ok";
toast(`UPID ${suffix} · HTTP ${result.status}`, toastLevel);
} catch (error) {
showUpidOutput(String(error));
toast(`UPID ${suffix} failed`, "error");
} finally {
setLoading(false);
}
}
function fillUpidFromLastResponse() {
const fromState = state.lastUpid || extractUpidFromPayload(
(() => {
try { return JSON.parse(state.lastResponseRaw); } catch { return state.lastResponseRaw; }
})(),
);
if (!fromState) {
toast("No UPID found in the last response", "warn");
showUpidOutput("Last response did not contain a UPID string in data.");
return;
}
applyCapturedUpid(fromState);
state.lastUpid = fromState;
toast("UPID loaded from last response", "ok");
}
function buildDemoDataHtml(data) {
const loaded = Boolean(data?.loaded);
const cephPiB = data?.ceph_capacity_pib;
@@ -5399,7 +5654,7 @@
<path d="M11 6h2"/>
</svg>
</a>
<a class="help-about-social-github" href="https://github.com/inecs" target="_blank" rel="noopener noreferrer" aria-label="GitHub" data-tooltip="github.com/inecs">
<a class="help-about-social-github" href="https://github.com/sergeyantropoff" target="_blank" rel="noopener noreferrer" aria-label="GitHub" data-tooltip="github.com/sergeyantropoff">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.8c-3 .7-3.6-1.3-3.6-1.3-.5-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.4 1.1 3 .8.1-.7.4-1.1.7-1.4-2.4-.3-5-1.2-5-5.5a4.3 4.3 0 0 1 1.2-3 4.3 4.3 0 0 1 3.2-1.3c1.2 0 2.2.4 3 1a7.5 7.5 0 0 1 4.6-1.6c.5 0 1 .1 1.5.2a4.3 4.3 0 0 1 1.2 3c0 3.3-2.6 4.1-5 4.3-1.1.9-1 1.4-1 3.6v2.7c0 .3.2.6.7.5A10 10 0 0 0 12 2z"/>
</svg>
@@ -5542,6 +5797,7 @@
if (except !== "params") closeParamsDrawer();
if (except !== "environment") closeEnvironmentDrawer();
if (except !== "data") closeDataDrawer();
if (except !== "upid") closeUpidDrawer();
if (except !== "url") closeUrlDrawer();
if (except !== "endpoints") closeEndpointsDrawer();
if (except !== "help") closeHelpDrawer();
@@ -6495,6 +6751,7 @@
try {
const result = await api(method, path.startsWith("/") ? path : `/${path}`, body);
showResponse(result);
captureUpidFromResult(result);
updateResolvedPath();
pushHistory(
method,
@@ -6647,6 +6904,11 @@
closeOtherPanels("data");
toggleDataDrawer(!els.dataDrawer.classList.contains("open"));
});
els.upidBadge?.addEventListener("click", (e) => {
e.stopPropagation();
closeOtherPanels("upid");
toggleUpidDrawer(!els.upidDrawer.classList.contains("open"));
});
els.themeToggle.addEventListener("click", () => toggleTheme());
els.helpBadge.addEventListener("click", (e) => {
e.stopPropagation();
@@ -6671,6 +6933,24 @@
els.environmentBackdrop.addEventListener("click", () => closeEnvironmentDrawer());
els.dataDrawerClose.addEventListener("click", () => closeDataDrawer());
els.dataBackdrop.addEventListener("click", () => closeDataDrawer());
els.upidDrawerClose?.addEventListener("click", () => closeUpidDrawer());
els.upidBackdrop?.addEventListener("click", () => closeUpidDrawer());
els.btnUpidStatus?.addEventListener("click", () => {
fetchUpidTask("status").catch((error) => showError(String(error)));
});
els.btnUpidLog?.addEventListener("click", () => {
fetchUpidTask("log").catch((error) => showError(String(error)));
});
els.btnUpidFromResponse?.addEventListener("click", () => fillUpidFromLastResponse());
els.upidValue?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
fetchUpidTask("status").catch((error) => showError(String(error)));
}
});
els.upidValue?.addEventListener("input", () => {
const node = parseUpidNode(els.upidValue.value);
if (node && els.upidNode) els.upidNode.value = node;
});
els.historyDrawerClose.addEventListener("click", () => closeHistoryDrawer());
els.historyBackdrop.addEventListener("click", () => closeHistoryDrawer());
els.historyClear.addEventListener("click", () => {