"""Full REST verb×path×majors matrix for pulumi-tests (Layer A HTTP contract). Probes ``methods_for_major(M)`` for majors 6–9 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, }