Files
proxmox-api-simulator/pulumi-tests/pulumi/pvelib/surface.py
T
Sergey Antropoff baa1f58ad0 Fix CI/pulumi blockers and document test suites with latest PASS results.
Stabilize node status, cluster-wide Ceph OSD ids, and QEMU cpu utilization so
status/list no longer 500 on model strings; align pulumi defaults to pve1;
add bilingual testing docs with the 2026-07-18 ci-all + pulumi-tests pass.
2026-07-18 09:38:28 +03:00

506 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sync contract surface probe against a live simulator (majors 69).
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
import json
import os
import re
import time
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
# Bundled revisions — keep in sync with app/web/contract_catalog.py
MAJOR_REVISIONS: dict[int, tuple[str, str]] = {
6: ("6.4-15", "96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724"),
7: ("7.4-16", "2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f"),
8: ("8.4.5", "fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa"),
9: ("9.2.3", "e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1"),
}
_PATH_RE = re.compile(r"\{([^{}]+)\}")
_FORBIDDEN = re.compile(
r"not supported in the emulator|not implemented in the simulator|"
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": "pve1",
"vmid": 100,
"storage": "local",
"pool": "testpool",
"userid": "root@pam",
"tokenid": "automation",
"realm": "pam",
"group": "admins",
"role": "Administrator",
"upid": "UPID:pve1:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0",
"iface": "net0",
"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] = {
"groupid": "admins",
"roleid": "Administrator",
"zone": "localnet",
"vnet": "vnet0",
"subnet": "10.0.0.0-24",
"controller": "evpn1",
"dns": "dns1",
"ipam": "pve",
"flag": "noout",
"osdid": "0",
"monid": "0",
"id": "example",
"cputype": "custom1",
"pci-id-or-mapping": "0000:00:1f.0",
"rule": "rule1",
"sid": "vm:100",
"pos": "0",
"cidr": "10.0.0.0/24",
"tokenid": "automation",
"fabric_id": "fab1",
"node_id": "pve1",
"url_seq": "1",
"route-map-id": "rm1",
"order": "10",
"userid": "root@pam",
"realm": "pam",
"name": "example",
"plugin": "example",
"target": "example",
}
CRITICAL_BUCKETS = frozenset(
{
"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",
)
def contracts_root() -> Path:
env = os.environ.get("CONTRACTS_ROOT")
if env:
return Path(env)
# Runner mounts repo at /workspace; local runs use repo-relative path.
here = Path(__file__).resolve()
candidates = [
Path("/workspace/contracts"),
here.parents[3] / "contracts", # pulumi-tests/pulumi/pvelib → repo
Path.cwd() / "contracts",
Path.cwd().parent / "contracts",
]
for path in candidates:
if path.is_dir():
return path
raise FileNotFoundError("contracts/ directory not found; set CONTRACTS_ROOT")
def load_snapshot(major: int) -> dict[str, Any]:
version, revision = MAJOR_REVISIONS[major]
path = contracts_root() / revision / "snapshot.json"
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("source_version") != version:
# Still usable; keep declared version from manifest pairing.
pass
return data
def _path_value(name: str) -> str:
value = _PATH_PARAM_EXAMPLES.get(name)
if value is None:
value = _EXTRA_PATH.get(name, "example")
return str(value)
def render_path(template: str) -> str:
def replace(match: re.Match[str]) -> str:
return quote(str(_path_value(match.group(1))), safe="@._-")
return _PATH_RE.sub(replace, template)
def _schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
if not schema:
return "example"
if schema.get("default") is not None:
return schema["default"]
enum = schema.get("enum") or []
if enum:
return enum[0]
if name is not None:
hinted = _PATH_PARAM_EXAMPLES.get(name)
if hinted is not None:
return hinted
typ = schema.get("type")
if typ == "array":
items = schema.get("items")
return [_schema_example(items)] if items else []
if typ == "object":
props = schema.get("properties") or {}
return {
key: _schema_example(defn, name=key)
for key, defn in props.items()
if not (isinstance(defn, dict) and defn.get("optional"))
}
if typ == "boolean":
return False
if typ == "integer":
minimum = schema.get("minimum")
return int(minimum) if minimum is not None else 1
if typ == "number":
minimum = schema.get("minimum")
return float(minimum) if minimum is not None else 1.0
return "example"
def body_for(method: dict[str, Any], path_template: str) -> dict[str, Any]:
path_names = set(_PATH_RE.findall(path_template))
payload: dict[str, Any] = {}
for parameter in method.get("parameters") or []:
name = parameter.get("name")
if not name or name in path_names:
continue
definition = parameter.get("definition") or {}
if definition.get("optional"):
continue
payload[name] = _schema_example(definition, name=name)
return payload
def classify(status: int, text: str) -> str:
if _FORBIDDEN.search(text or ""):
return "unsupported_message"
if status == 501:
return "unimplemented_501"
if 200 <= status < 300:
return "success_2xx"
if status in {401, 403}:
return "auth_401_403"
if status in {400, 404, 405, 409, 412, 422, 423}:
return "client_4xx"
if status >= 500:
return "server_5xx"
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,
major: int,
snapshot: dict[str, Any],
) -> dict[str, Any]:
started = time.monotonic()
apply = client.post("/ui/api/contract/apply", params={"major": major})
apply.raise_for_status()
applied = apply.json()
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)
for path in snapshot.get("paths") or []
for method in path.get("methods") or []
]
order = {"GET": 0, "PUT": 1, "POST": 2, "DELETE": 3}
methods.sort(key=lambda item: (order.get(str(item[1].get("verb", "")).upper(), 9), item[0]))
for path_template, method in methods:
verb = str(method.get("verb", "")).upper()
url = f"/api2/json{render_path(path_template)}"
headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
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)
elif verb == "POST":
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)
else:
by_verb[verb or "UNKNOWN"]["exception"] += 1
item = {
"verb": verb or "UNKNOWN",
"path": path_template,
"error": f"unsupported HTTP verb {verb!r}",
"bucket": "exception",
"ok": False,
}
failures.append(item)
method_results.append(item)
continue
except Exception as exc: # noqa: BLE001
by_verb[verb]["exception"] += 1
item = {
"verb": verb,
"path": path_template,
"error": str(exc)[:200],
"bucket": "exception",
"ok": False,
}
failures.append(item)
method_results.append(item)
continue
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 = {
"verb": verb,
"path": path_template,
"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)
success_2xx = sum(c.get("success_2xx", 0) for c in by_verb.values())
probed = len(method_results)
empty_reasons: list[str] = []
if not isinstance(applied, dict) or not applied:
empty_reasons.append("apply response empty")
elif applied.get("ok") is False:
empty_reasons.append(f"apply not ok: {applied!r}")
if declared <= 0:
empty_reasons.append("declared method_count is 0")
if probed <= 0:
empty_reasons.append("no methods probed")
if probed != declared:
empty_reasons.append(
f"incomplete coverage: probed {probed} of {declared} declared methods"
)
if success_2xx <= 0:
empty_reasons.append("success_2xx count is 0")
if empty_reasons:
for reason in empty_reasons:
failures.append(
{
"verb": "META",
"path": f"major/{major}",
"bucket": "exception",
"ok": False,
"error": reason,
}
)
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()
),
"failure_count": critical,
"failures": failures,
"ok": critical == 0,
"time": time.monotonic() - started,
"methods": method_results,
"head_methods": head_results,
}
def run_surface(
*,
majors: tuple[int, ...] = (6, 7, 8, 9),
api_url: str | None = None,
) -> list[dict[str, Any]]:
base = (api_url or os.environ.get("API_URL", "http://simulator:8006")).rstrip("/")
user = os.environ.get("API_USER", "root@pam")
password = os.environ.get("API_PASSWORD", "secret")
results: list[dict[str, Any]] = []
with httpx.Client(base_url=base, timeout=60.0) as client:
# Prefer env credentials over hard-coded login body.
ticket = client.post(
"/api2/json/access/ticket",
data={"username": user, "password": password},
)
ticket.raise_for_status()
data = ticket.json()["data"]
client.cookies.set("PVEAuthCookie", data["ticket"])
csrf = str(data["CSRFPreventionToken"])
for major in majors:
snapshot = load_snapshot(major)
results.append(probe_major(client, csrf, major, snapshot))
return results