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:
@@ -0,0 +1 @@
|
||||
encryptionsalt: v1:Im3SuXTFiBk=:v1:oUxFIClVSpmc6Jkb:AXfMbHESIZWB3tnQLGNdQNp2ICM+UA==
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:7PSO8VI3JSQ=:v1:LKcWFamTow4SZLl4:ItaMZLsAtkKhooVaycXnDR7F2dWwmA==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,33 @@ def _surface_error(major: dict[str, Any]) -> str:
|
||||
return f"{len(fails)} critical: " + "; ".join(parts)
|
||||
|
||||
|
||||
def _verb_histogram_rows(surface: list[dict[str, Any]]) -> list[str]:
|
||||
rows: list[str] = []
|
||||
for major in surface:
|
||||
histogram = major.get("verb_histogram") or {}
|
||||
if not histogram:
|
||||
# Fall back to by_verb totals when slim payload lacks histogram.
|
||||
by_verb = major.get("by_verb") or {}
|
||||
histogram = {
|
||||
verb: {"total": sum(buckets.values()), "buckets": buckets}
|
||||
for verb, buckets in by_verb.items()
|
||||
}
|
||||
for verb, info in sorted(histogram.items()):
|
||||
buckets = info.get("buckets") or {}
|
||||
bucket_txt = ", ".join(
|
||||
f"{name}={count}" for name, count in sorted(buckets.items()) if count
|
||||
)
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(major.get('version') or major.get('major')))}</td>"
|
||||
f"<td>{html.escape(str(verb))}</td>"
|
||||
f"<td>{html.escape(str(info.get('total') or 0))}</td>"
|
||||
f"<td><code>{html.escape(bucket_txt)}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def write_html(payload: dict[str, Any], path: Path) -> None:
|
||||
surface = payload.get("surface") or []
|
||||
scenarios = payload.get("scenarios") or []
|
||||
@@ -240,6 +267,20 @@ code {{ font-family: var(--mono); font-size: .85em; }}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Verb histogram (incl. synthetic HEAD)</h2>
|
||||
<p class="lead">
|
||||
Contract verbs GET/PUT/POST/DELETE count toward coverage.
|
||||
HEAD is probed on every GET path for the matrix but is not part of declared/probed.
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Major</th><th>Verb</th><th>Total</th><th>Buckets</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(_verb_histogram_rows(surface)) or '<tr><td colspan="4" class="empty">No histogram</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Critical surface failures</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
|
||||
@@ -22,9 +22,10 @@ sys.path.insert(0, str(ROOT))
|
||||
|
||||
def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
from pulumi import automation as auto
|
||||
import uuid
|
||||
|
||||
program_dir = PROGRAMS / "lifecycle"
|
||||
stack_name = f"hxlife{os.getpid()}{int(time.time())}"
|
||||
stack_name = f"hxlife-{uuid.uuid4().hex[:12]}"
|
||||
os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "hx-test-passphrase")
|
||||
state = HERE / ".pulumi-state"
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
@@ -43,6 +44,21 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
|
||||
started = time.monotonic()
|
||||
stack = None
|
||||
|
||||
def _cleanup() -> None:
|
||||
if stack is None:
|
||||
return
|
||||
try:
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
message = str(exc).lower()
|
||||
if "no stack named" not in message and "not found" not in message:
|
||||
raise
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Pass env (incl. PULUMI_BACKEND_URL) at workspace creation — assigning
|
||||
# workspace.env_vars after create_or_select_stack can lose stack selection.
|
||||
@@ -67,11 +83,7 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
item = outputs.get(key)
|
||||
if item is None or getattr(item, "value", None) in (None, "", {}, []):
|
||||
raise RuntimeError(f"lifecycle export {key!r} is empty")
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
_cleanup()
|
||||
elapsed = time.monotonic() - started
|
||||
if ids:
|
||||
return {
|
||||
@@ -89,15 +101,10 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
stderr = getattr(exc, "stderr", None)
|
||||
if stderr:
|
||||
detail = f"{detail}\n{stderr}"
|
||||
if stack is not None:
|
||||
try:
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001
|
||||
detail = f"{detail}\ncleanup: {cleanup_exc}"
|
||||
return {
|
||||
"ok": False,
|
||||
"scenarios": [
|
||||
@@ -166,9 +173,17 @@ def main() -> int:
|
||||
status = "ok" if item["ok"] else "FAIL"
|
||||
print(
|
||||
f" {status} PVE {item['version']}: declared={item['declared']} "
|
||||
f"probed={item['probed']} critical={item['failure_count']} "
|
||||
f"probed={item['probed']} head={item.get('head_probed', 0)} "
|
||||
f"critical={item['failure_count']} "
|
||||
f"2xx={item['success_2xx']} 4xx={item['client_4xx']}"
|
||||
)
|
||||
hist = item.get("verb_histogram") or {}
|
||||
if hist:
|
||||
parts = [
|
||||
f"{verb}:{info.get('total', 0)}"
|
||||
for verb, info in sorted(hist.items())
|
||||
]
|
||||
print(f" verbs: {', '.join(parts)}")
|
||||
if not item["ok"]:
|
||||
for fail in (item.get("failures") or [])[:10]:
|
||||
print(
|
||||
@@ -177,11 +192,12 @@ def main() -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Slim JSON: drop full method lists (keep failures)
|
||||
# Slim JSON: drop full method lists (keep failures + verb histogram)
|
||||
surface_slim = []
|
||||
for item in surface:
|
||||
slim = dict(item)
|
||||
slim.pop("methods", None)
|
||||
slim.pop("head_methods", None)
|
||||
surface_slim.append(slim)
|
||||
|
||||
coverage = build_coverage(surface_slim, majors=list(majors))
|
||||
|
||||
Reference in New Issue
Block a user