Files
vmware-api-simulator/pulumi-tests/lib/rest_matrix.py
T

535 lines
18 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.
"""Full REST verb×path×majors matrix for pulumi-tests (Layer A HTTP contract).
Probes ``methods_for_major(M)`` for majors 69 with session auth.
Verbs: whatever is in IMPLEMENTED (GET/PUT/PATCH/POST/DELETE) plus a synthetic
HEAD for every GET path. Pass requires ``critical == 0`` and
``probed == declared`` per major and in aggregate.
100% coverage here means the HTTP contract matrix — not pulumi-vsphere resource
count (Layer B).
"""
from __future__ import annotations
import json
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from base64 import b64encode
from collections import Counter
from typing import Any
from urllib.parse import urlencode
from app.vsphere.contracts.matrix import VERSIONS, methods_for_major
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED
_PATH_SUBS = {
"{vm}": "vm-101",
"{host}": "host-11",
"{datastore}": "datastore-31",
"{task}": "task-1",
"{snapshot}": "snapshot-missing",
"{category_id}": "cat-lab-1",
"{tag_id}": "tag-lab-1",
"{item_id}": "item-ubuntu",
"{library_item_id}": "item-ubuntu",
"{library_id}": "lib-local-1",
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
"{permission_id}": "999999",
"{policy}": "policy-default",
"{disk}": "2000",
"{nic}": "4000",
"{cdrom}": "3000",
"{floppy}": "8000",
"{port}": "9000",
"{adapter}": "1000",
"{provider}": "vsphere.local",
"{supervisor}": "supervisor-1",
"{namespace}": "ns-lab-1",
"{role}": "ReadOnly",
"{zone}": "zone-1",
"{project}": "project-1",
"{domain}": "lab.local",
"{service}": "vsphere-ui",
"{depot}": "depot-1",
"{component}": "component-1",
"{image}": "image-1",
"{draft}": "draft-1",
"{connection}": "connection-1",
"{vpc}": "vpc-1",
"{subnet}": "subnet-1",
"{session_id}": "session-lab-1",
"{download_session_id}": "session-lab-1",
"{update_session_id}": "session-lab-1",
"{subscription_id}": "sub-1",
"{usage_id}": "usage-1",
"{version}": "1",
"{chain}": "chain-1",
"{node}": "node-1",
"{profile}": "profile-1",
"{interface}": "nic0",
"{core}": "core-1",
"{network}": "network-41",
"{commit}": "commit-lab-1",
}
_ACCEPT_CLIENT = {400, 401, 403, 404, 405, 409, 412, 422}
_SKIP_DELETE = frozenset(
{
("DELETE", "/api/session"),
("DELETE", "/rest/com/vmware/cis/session"),
}
)
# Collection / entity GETs that must be non-empty after small seed (major 9).
_INVENTORY_CRITICAL = {
"/api/vcenter/vm",
"/api/vcenter/host",
"/api/vcenter/datastore",
"/api/vcenter/network",
"/api/vcenter/cluster",
"/api/vcenter/datacenter",
"/api/vcenter/folder",
"/api/vcenter/resource-pool",
"/api/cis/tagging/category",
"/api/cis/tagging/tag",
"/api/content/library",
"/api/content/local-library",
"/api/vcenter/network/dvs",
"/api/vcenter/storage/policies",
"/api/vcenter/privilege",
"/api/vcenter/authorization/roles",
"/api/vcenter/authorization/permissions",
"/api/cis/tasks",
"/api/esx/settings/clusters/{cluster}/software",
"/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
"/api/appliance/access/ssh",
"/api/appliance/services",
}
# GET paths that may legally return empty/null bodies.
_EMPTY_OK_GET = frozenset(
{
"/api/session",
"/rest/com/vmware/cis/session",
}
)
def _base() -> str:
explicit = os.environ.get("VSPHERE_BASE")
if explicit:
return explicit.rstrip("/")
server = os.environ.get("VSPHERE_SERVER", "localhost")
if server.startswith("http://") or server.startswith("https://"):
return server.rstrip("/")
return f"https://{server}"
def _creds() -> tuple[str, str]:
return (
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
)
def _ctx() -> ssl.SSLContext | None:
if not _base().startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def concrete_path(path: str) -> str:
out = path
for key, value in _PATH_SUBS.items():
out = out.replace(key, value)
return re.sub(r"\{([A-Za-z0-9_]+)\}", r"probe-\1", out)
def request(
method: str,
path: str,
*,
headers: dict[str, str],
data: bytes | None = None,
) -> tuple[int, str]:
if "?" in path:
base_path, query = path.split("?", 1)
url = f"{_base()}{concrete_path(base_path)}?{query}"
else:
url = f"{_base()}{concrete_path(path)}"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
body = resp.read().decode("utf-8", errors="replace")
return int(resp.status), body
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
return int(error.code), body
def login() -> str:
user, password = _creds()
basic = b64encode(f"{user}:{password}".encode()).decode()
code, body = request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
if code not in {200, 201}:
raise RuntimeError(f"session failed: {code} {body[:200]}")
return json.loads(body)
def declared_routes(major: int) -> list[tuple[str, str]]:
"""Registry routes for a major, plus synthetic HEAD for each GET."""
methods = methods_for_major(major)
base = [(verb, path) for (verb, path) in sorted(methods) if (verb, path) not in _SKIP_DELETE]
heads = [("HEAD", path) for verb, path in base if verb == "GET"]
# Keep HEAD adjacent after its GET in verb order during probe via sort key.
return base + heads
def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
if verb not in {"POST", "PUT", "PATCH"}:
return path, None
if path.endswith("/power") and verb == "POST":
if "/guest/power" in path:
return f"{path}?action=reboot", b"{}"
return f"{path}?action=start", b"{}"
if path.endswith("/maintenance") and verb == "POST":
return f"{path}?action=enter", b"{}"
if path.endswith("/folder/{folder}") and verb == "POST":
# Do not rename seed folder MOIDs (breaks /Datacenter/vm/... inventory paths).
return (
"/api/vcenter/folder/folder-missing-matrix?action=rename",
json.dumps({"name": "folder-renamed-probe"}).encode(),
)
if path == "/api/cis/tasks" and verb == "POST":
return f"{path}?action=list", json.dumps({"filter_spec": {}}).encode()
suffix = secrets.token_hex(4)
bodies: dict[str, dict[str, Any]] = {
"/api/vcenter/vm": {
"name": f"matrix-probe-vm-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
"cpu_count": 1,
"memory_size_MiB": 512,
},
# Missing parent → 404 (client_4xx). Avoid creating extra Datacenter/Cluster
# trees that leave a second ResourcePool named "Resources" for govmomi.
"/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}", "folder": "folder-missing-matrix"},
"/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}", "folder": "folder-missing-matrix"},
"/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"},
"/api/vcenter/resource-pool": {
"name": f"probe-rp-{suffix}",
"parent": "resgroup-missing-matrix",
},
"/api/vcenter/network/dvs": {"name": f"probe-dvs-{suffix}"},
"/api/vcenter/network/dvpg": {
"name": f"probe-dvpg-{suffix}",
"dvs": "dvs-51",
"vlan_id": 10,
},
"/api/cis/tagging/category": {
"create_spec": {
"name": f"probe-cat-{suffix}",
"description": "probe",
"cardinality": "MULTIPLE",
"associable_types": [],
}
},
"/api/cis/tagging/tag": {
"create_spec": {
"name": f"probe-tag-{suffix}",
"category_id": "missing-category",
"description": "x",
}
},
"/api/cis/tagging/tag-association": {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
},
"/api/content/local-library": {"create_spec": {"name": f"probe-lib-{suffix}"}},
"/api/content/library/item": {
"create_spec": {
"library_id": "lib-missing",
"name": f"probe-item-{suffix}",
"type": "ovf",
}
},
"/api/vcenter/ovf/library-item/{item_id}": {
"deployment_spec": {"name": f"ovf-probe-{suffix}"},
"target": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
},
"/api/vcenter/authorization/permissions": {
"principal": "readonly@vsphere.local",
"role": "ReadOnly",
"entity": "datacenter-21",
},
"/api/vcenter/datastore/{datastore}/files": {
"path": f"/probe-{suffix}.txt",
"size": 1,
"type": "FILE",
},
"/api/vcenter/vm/{vm}/hardware/cpu": {"count": 2},
"/api/vcenter/vm/{vm}/hardware/memory": {"size_MiB": 1024},
"/api/vcenter/vm/{vm}/hardware/disk": {"type": "SCSI", "new_vmdk": {"capacity": 1024}},
"/api/vcenter/vm/{vm}/hardware/ethernet": {
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
},
"/api/vcenter/vm/{vm}/snapshots": {"name": f"probe-snap-{suffix}"},
"/api/vcenter/vm/{vm}/snapshots/{snapshot}": {"action": "revert"},
"/api/vcenter/vm/{vm}/clone": {
"name": f"probe-clone-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11"},
},
"/api/vcenter/vm/{vm}/relocate": {"placement": {"host": "host-12"}},
"/api/vcenter/vm/{vm}/tools": {"action": "upgrade"},
"/api/vcenter/vm/{vm}/console/tickets": {"type": "WEBMKS"},
"/api/vcenter/vm/{vm}/guest/customization": {"name": {"name": f"guest-probe-{suffix}"}},
"/api/vcenter/vm/{vm}": {"action": "unregister"},
}
body = bodies.get(path, {})
return path, json.dumps(body).encode()
def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
params = urlencode({"major": major})
code, body = request(
"POST",
f"/ui/api/contract/apply?{params}",
headers=headers,
)
if code >= 400:
raise RuntimeError(f"contract apply major={major} failed: {code} {body[:200]}")
return json.loads(body)
def _classify(verb: str, path: str) -> str:
lookup = verb if verb != "HEAD" else "GET"
if (lookup, path) in CORE_IMPLEMENTED:
return "deep"
status = IMPLEMENTED.get((lookup, path))
if status == "stub":
return "stub"
return "deep" if status == "implemented" else "unknown"
def _is_empty_payload(body: str) -> bool:
if not body or not body.strip():
return True
try:
parsed = json.loads(body)
except json.JSONDecodeError:
return False
return parsed in ([], {}, None, "")
def _record_result(
*,
major: int,
verb: str,
path: str,
code: int,
body: str,
buckets: Counter[str],
failures: list[dict[str, Any]],
deep_stub: Counter[str],
) -> None:
kind = _classify(verb, path)
deep_stub[kind] += 1
def _fail(expected: str, *, bucket: str) -> None:
buckets[bucket] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"critical": True,
"body": body[:200],
"expected": expected,
}
)
if 200 <= code < 300:
buckets["success_2xx"] += 1
if kind == "stub":
buckets["stub_ok"] += 1
# HEAD bodies are always empty by design.
if verb == "HEAD":
return
if major == 9 and verb == "GET" and path not in _EMPTY_OK_GET:
if '"stub": true' in body or '"stub":true' in body:
if path in _INVENTORY_CRITICAL or kind == "deep":
_fail("non-stub JSON", bucket="stub_marker")
elif path in _INVENTORY_CRITICAL and _is_empty_payload(body):
_fail("non-empty seeded data", bucket="empty_inventory")
return
# Synthetic HEAD must be served (middleware); 405 is critical for HEAD only.
if verb == "HEAD" and code == 405:
_fail("HEAD supported via GET route", bucket="head_405")
return
if code in _ACCEPT_CLIENT:
buckets["client_4xx"] += 1
return
if code == 501:
_fail("no 501 on registered route", bucket="unexpected_501")
return
if code >= 500:
_fail("no 5xx", bucket="server_5xx")
return
_fail(f"unexpected status {code}", bucket=f"other_{code}")
def probe_major(major: int, session: str) -> dict[str, Any]:
headers = {
"vmware-api-session-id": session,
"Content-Type": "application/json",
"Accept": "application/json",
}
applied = apply_major(major, headers)
routes = declared_routes(major)
declared = len(routes)
verb_order = {"GET": 0, "HEAD": 1, "PUT": 2, "PATCH": 3, "POST": 4, "DELETE": 5}
routes = sorted(routes, key=lambda item: (verb_order.get(item[0], 9), item[1]))
buckets: Counter[str] = Counter()
deep_stub: Counter[str] = Counter()
failures: list[dict[str, Any]] = []
probed = 0
by_verb: Counter[str] = Counter()
for verb, path in routes:
by_verb[verb] += 1
if verb == "DELETE" and path in {
"/api/vcenter/datacenter/{datacenter}",
"/api/vcenter/cluster/{cluster}",
"/api/vcenter/folder/{folder}",
"/api/vcenter/resource-pool/{resource_pool}",
"/api/vcenter/vm/{vm}",
"/api/content/local-library/{library_id}",
"/api/content/library/item/{library_item_id}",
}:
# Avoid destroying seed MOIDs — probe missing ids (expect 404).
url_path = path
for token, missing in (
("{vm}", "vm-missing-matrix"),
("{datacenter}", "dc-missing"),
("{cluster}", "cluster-missing"),
("{folder}", "folder-missing"),
("{resource_pool}", "rp-missing"),
("{library_id}", "lib-missing-matrix"),
("{library_item_id}", "item-missing-matrix"),
):
url_path = url_path.replace(token, missing)
code, body = request(verb, url_path, headers=headers)
elif verb == "HEAD":
url_path = path
if path == "/api/content/library/item":
url_path = f"{path}?library_id=lib-local-1"
code, body = request("HEAD", url_path, headers=headers)
else:
url_path, data = _payload_for(verb, path)
if verb == "GET" and path == "/api/content/library/item":
url_path = f"{url_path}?library_id=lib-local-1"
code, body = request(verb, url_path, headers=headers, data=data)
probed += 1
_record_result(
major=major,
verb=verb,
path=path,
code=code,
body=body,
buckets=buckets,
failures=failures,
deep_stub=deep_stub,
)
critical = sum(1 for f in failures if f.get("critical"))
coverage_ok = probed == declared and critical == 0
return {
"major": major,
"version": applied.get("runtime_version"),
"declared": declared,
"method_count": len(methods_for_major(major)),
"by_verb": dict(by_verb),
"probed": probed,
"probed_eq_declared": probed == declared,
"buckets": dict(buckets),
"deep_vs_stub": dict(deep_stub),
"failures": failures,
"failed": len(failures),
"critical": critical,
"coverage_line": f"{probed - critical}/{declared}",
"ok": coverage_ok,
}
def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
"""Probe IMPLEMENTED × majors (+ HEAD). Returns summary for suite JSON/HTML."""
if majors is None:
majors = [6, 7, 8, 9]
for major in majors:
if major not in VERSIONS:
raise ValueError(f"unknown major {major}")
session = login()
reports: list[dict[str, Any]] = []
all_failures: list[dict[str, Any]] = []
verb_totals: Counter[str] = Counter()
declared_total = 0
probed_total = 0
critical_total = 0
for major in majors:
report = probe_major(major, session)
reports.append(report)
all_failures.extend(report["failures"])
declared_total += int(report["declared"])
probed_total += int(report["probed"])
critical_total += int(report["critical"])
for verb, count in report["by_verb"].items():
verb_totals[verb] += count
session = login()
apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"})
coverage_line = f"{probed_total - critical_total}/{declared_total}"
ok = critical_total == 0 and probed_total == declared_total and all(r["ok"] for r in reports)
return {
"base": _base(),
"majors": reports,
"by_verb": dict(verb_totals),
"declared": declared_total,
"probed": probed_total,
"total": probed_total,
"critical": critical_total,
"failed": critical_total,
"failures": all_failures[:120],
"coverage_line": coverage_line,
"probed_eq_declared": probed_total == declared_total,
"ok": ok,
}