"""Full REST verb×path×majors matrix for pulumi-tests (hybrid suite). Reuses path substitution / session patterns from scripts/vsphere_full_matrix_probe.py. Pass rules match that probe: no 5xx/501; inventory GETs nonempty + non-stub on major 9. """ 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, catalog_entries_for_major, 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_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} _INVENTORY_CRITICAL = { "/api/vcenter/vm", "/api/vcenter/host", "/api/vcenter/datastore", "/api/vcenter/network", "/api/vcenter/cluster", "/api/cis/tagging/category", "/api/content/library", "/api/esx/settings/clusters/{cluster}/software", "/api/vcenter/namespace-management/supervisors/{supervisor}/summary", "/api/appliance/access/ssh", "/api/appliance/services", } 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]: # Paths may already include query strings from _payload_for. 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 _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": return f"{path}?action=rename", json.dumps({"name": "folder-renamed-probe"}).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, }, "/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}"}, "/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}"}, "/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"}, "/api/vcenter/resource-pool": {"name": f"probe-rp-{suffix}", "parent": "resgroup-22"}, "/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: status = CORE_IMPLEMENTED.get((verb, path)) or IMPLEMENTED.get((verb, path)) if (verb, path) in CORE_IMPLEMENTED: return "deep" if status == "stub": return "stub" return "deep" if status == "implemented" else "unknown" 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 if 200 <= code < 300: buckets["success_2xx"] += 1 if kind == "stub": buckets["stub_ok"] += 1 if major == 9 and verb == "GET" and body: if '"stub": true' in body or '"stub":true' in body: if path in _INVENTORY_CRITICAL or kind == "deep": buckets["stub_marker"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "kind": kind, "status": code, "body": body[:200], "expected": "non-stub JSON", } ) elif path in _INVENTORY_CRITICAL: try: parsed = json.loads(body) except json.JSONDecodeError: parsed = None empty = parsed in ([], {}, None, "") if empty: buckets["empty_inventory"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "kind": kind, "status": code, "body": body[:200], "expected": "non-empty seeded data", } ) elif code in _ACCEPT_CLIENT: buckets["client_4xx"] += 1 elif code == 501: buckets["unexpected_501"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "kind": kind, "status": code, "body": body[:200], } ) elif code >= 500: buckets["server_5xx"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "kind": kind, "status": code, "body": body[:200], } ) else: buckets[f"other_{code}"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "kind": kind, "status": code, "body": body[:200], } ) 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) active = methods_for_major(major) verb_order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4} entries = sorted( catalog_entries_for_major(major), key=lambda e: (verb_order.get(e["verb"], 9), e["path"]), ) buckets: Counter[str] = Counter() deep_stub: Counter[str] = Counter() failures: list[dict[str, Any]] = [] probed = 0 by_verb: Counter[str] = Counter() for entry in entries: verb = entry["verb"] path = entry["path"] by_verb[verb] += 1 if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}: continue 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}", }: if path.endswith("{vm}"): url_path = path.replace("{vm}", "vm-missing-matrix") elif path.endswith("{datacenter}"): url_path = path.replace("{datacenter}", "dc-missing") elif path.endswith("{cluster}"): url_path = path.replace("{cluster}", "cluster-missing") elif path.endswith("{folder}"): url_path = path.replace("{folder}", "folder-missing") else: url_path = path.replace("{resource_pool}", "rp-missing") code, body = request(verb, 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, ) above_floor = 0 for (verb, path), _status in sorted(IMPLEMENTED.items()): if (verb, path) in active: continue if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}: continue url_path, data = _payload_for(verb, path) code, body = request(verb, url_path, headers=headers, data=data) above_floor += 1 probed += 1 by_verb[verb] += 1 _record_result( major=major, verb=verb, path=path, code=code, body=body, buckets=buckets, failures=failures, deep_stub=deep_stub, ) return { "major": major, "version": applied.get("runtime_version"), "method_count": len(entries), "by_verb": dict(by_verb), "probed": probed, "above_floor_checked": above_floor, "buckets": dict(buckets), "deep_vs_stub": dict(deep_stub), "failures": failures, "failed": len(failures), } def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]: """Probe IMPLEMENTED × majors. Returns summary suitable 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() for major in majors: report = probe_major(major, session) reports.append(report) all_failures.extend(report["failures"]) 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"}) total = sum(r["probed"] for r in reports) failed = len(all_failures) return { "base": _base(), "majors": reports, "by_verb": dict(verb_totals), "total": total, "failed": failed, "failures": all_failures[:120], "ok": failed == 0, }