Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.

- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional
  backups, snapshots, HA, replication, Ceph capacity, and OSD totals
  (10 / 100 / 500) plus matching node disks and crush/pg metadata
- Enrich handler responses for apt, certificates, qemu/lxc status, storage,
  SDN, metrics export, and related cluster/node dumps
- Flatten nested body_example fields into PARAMS and sync the request body
  via dotted paths (oVirt-style)
- Restyle DATA controls as size cards with full-width Reset to minimal /
  Refresh stats; unload reloads the minimal cluster
This commit is contained in:
Sergey Antropoff
2026-07-18 08:46:11 +03:00
parent 48df10b17e
commit 0773f721ea
77 changed files with 4870 additions and 855 deletions
+156 -4
View File
@@ -2,6 +2,12 @@
Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but
talks HTTP to ``API_URL`` instead of an in-process ASGI app.
Layer A matrix:
- every declared path+verb (GET/PUT/POST/DELETE)
- synthetic HEAD on each GET path (histogram only; not counted in declared)
- ticket + CSRF on mutations; form-urlencoded bodies
- Proxmox ``{ "data": … }`` envelope checks on 2xx
"""
from __future__ import annotations
@@ -31,6 +37,11 @@ _FORBIDDEN = re.compile(
r"handler pending for this contract method|is not supported in the (emulator|simulator)",
re.I,
)
_UPID_RE = re.compile(
r"^UPID:[A-Za-z0-9][A-Za-z0-9_-]*:"
r"[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:"
r"[A-Za-z0-9_-]+:[^:]*:[^:]+:$"
)
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01",
@@ -50,6 +61,11 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"key": "cpu",
"digest": "00000000",
"name": "example",
"size": "1G",
"filename": "vm-100-disk-0.raw",
"certificates": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
"contact": "mailto:admin@example.com",
"clustername": "lab",
}
_EXTRA_PATH: dict[str, object] = {
@@ -85,7 +101,36 @@ _EXTRA_PATH: dict[str, object] = {
}
CRITICAL_BUCKETS = frozenset(
{"unimplemented_501", "unsupported_message", "server_5xx", "exception"}
{
"unimplemented_501",
"unsupported_message",
"server_5xx",
"exception",
"empty_success_body",
"wrong_returns_envelope",
}
)
# Path suffixes that real PVE usually runs via fork_worker → UPID string.
_WORKER_SUFFIXES = (
"/status/start",
"/status/stop",
"/status/shutdown",
"/status/reboot",
"/status/reset",
"/status/suspend",
"/status/resume",
"/clone",
"/migrate",
"/remote_migrate",
"/snapshot",
"/rollback",
"/template",
"/resize",
"/move_disk",
"/move_volume",
"/vzdump",
"/apt/update",
)
@@ -195,6 +240,64 @@ def classify(status: int, text: str) -> str:
return f"other_{status}"
def returns_type(method: dict[str, Any]) -> str:
returns = method.get("returns") or {}
if isinstance(returns, dict):
return str(returns.get("type") or "")
return ""
def looks_like_worker(path_template: str, verb: str) -> bool:
if verb.upper() not in {"POST", "PUT"}:
return False
lowered = path_template.lower()
return any(lowered.endswith(suffix) for suffix in _WORKER_SUFFIXES)
def classify_envelope(
*,
status: int,
text: str,
method: dict[str, Any],
path_template: str,
verb: str,
) -> str | None:
"""Return an extra critical bucket for 2xx envelope/returns mismatches, else None."""
if not (200 <= status < 300) or verb.upper() == "HEAD":
return None
returns = method.get("returns") or {}
rtype = returns_type(method)
try:
payload = json.loads(text) if text else None
except json.JSONDecodeError:
return "empty_success_body"
if not isinstance(payload, dict) or "data" not in payload:
return "empty_success_body"
data = payload.get("data")
if rtype == "null":
return None
if rtype == "string":
if data is None:
return "wrong_returns_envelope"
if looks_like_worker(path_template, verb):
if not isinstance(data, str) or not _UPID_RE.fullmatch(data):
return "wrong_returns_envelope"
return None
if isinstance(data, str):
return None
# Older PVE schemas sometimes mark list endpoints as opaque ``string``
# (empty properties, no description). Accept non-null structured data.
if isinstance(returns, dict) and not (
returns.get("description") or returns.get("properties") or returns.get("enum")
):
return None
return "wrong_returns_envelope"
if rtype in {"array", "object"} and data is None:
return "wrong_returns_envelope"
return None
def probe_major(
client: httpx.Client,
csrf: str,
@@ -209,6 +312,8 @@ def probe_major(
by_verb: dict[str, Counter[str]] = defaultdict(Counter)
failures: list[dict[str, Any]] = []
method_results: list[dict[str, Any]] = []
head_results: list[dict[str, Any]] = []
get_paths: set[str] = set()
methods: list[tuple[str, dict[str, Any]]] = [
(path["path"], method)
@@ -225,6 +330,7 @@ def probe_major(
params = body_for(method, path_template)
try:
if verb == "GET":
get_paths.add(path_template)
response = client.get(url, headers=headers, params=params or None)
elif verb == "PUT":
response = client.put(url, data=params or {}, headers=headers)
@@ -232,9 +338,7 @@ def probe_major(
response = client.post(url, data=params or {}, headers=headers)
elif verb == "DELETE":
# Proxmox accepts delete identifiers as form or query params.
response = client.request(
"DELETE", url, data=params or {}, headers=headers
)
response = client.request("DELETE", url, data=params or {}, headers=headers)
else:
by_verb[verb or "UNKNOWN"]["exception"] += 1
item = {
@@ -262,6 +366,15 @@ def probe_major(
text = response.text
bucket = classify(response.status_code, text)
envelope = classify_envelope(
status=response.status_code,
text=text,
method=method,
path_template=path_template,
verb=verb,
)
if envelope is not None:
bucket = envelope
by_verb[verb][bucket] += 1
ok = bucket not in CRITICAL_BUCKETS
item = {
@@ -270,12 +383,40 @@ def probe_major(
"status": response.status_code,
"bucket": bucket,
"ok": ok,
"returns": returns_type(method),
}
if not ok:
item["body"] = text[:240]
failures.append(item)
method_results.append(item)
# Synthetic HEAD for every GET path (Starlette mirrors GET handlers).
for path_template in sorted(get_paths):
url = f"/api2/json{render_path(path_template)}"
try:
response = client.request("HEAD", url)
text = response.text
bucket = classify(response.status_code, text)
except Exception as exc: # noqa: BLE001
bucket = "exception"
text = str(exc)[:200]
response = None
by_verb["HEAD"][bucket] += 1
ok = bucket not in CRITICAL_BUCKETS
item = {
"verb": "HEAD",
"path": path_template,
"status": getattr(response, "status_code", None),
"bucket": bucket,
"ok": ok,
"synthetic": True,
}
if not ok:
item["body"] = text[:240]
item["error"] = text[:200]
failures.append(item)
head_results.append(item)
version, _ = MAJOR_REVISIONS[major]
declared = int(snapshot.get("method_count") or len(method_results))
critical = len(failures)
@@ -309,13 +450,23 @@ def probe_major(
)
critical = len(failures)
verb_histogram = {
verb: {
"total": sum(counter.values()),
"buckets": dict(counter),
}
for verb, counter in sorted(by_verb.items())
}
return {
"major": major,
"version": snapshot.get("source_version") or version,
"apply": applied,
"declared": declared,
"probed": probed,
"head_probed": len(head_results),
"by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
"verb_histogram": verb_histogram,
"success_2xx": success_2xx,
"client_4xx": sum(
c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values()
@@ -325,6 +476,7 @@ def probe_major(
"ok": critical == 0,
"time": time.monotonic() - started,
"methods": method_results,
"head_methods": head_results,
}