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:
+66
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user