#!/usr/bin/env python3 """Probe every registered vSphere REST method for majors 6–9 (GET/POST/PATCH/PUT/DELETE). Acceptable statuses: 2xx, 400/404/405/409/422 (validation / missing id). Fail on: 5xx, unexpected exceptions, empty inventory on seeded GETs. Lab policy: catalog floors are browse-only — runtime never expects HTTP 501. """ from __future__ import annotations import argparse import json import os import secrets import ssl import sys 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 IMPLEMENTED BASE = os.getenv("VSPHERE_BASE", "https://localhost") USER = os.getenv("VSPHERE_USER", "administrator@vsphere.local") PASSWORD = os.getenv("VSPHERE_PASSWORD", "VMware1!") _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} def _ctx() -> ssl.SSLContext | None: if not BASE.startswith("https://"): return None return ssl._create_unverified_context() # noqa: S323 def _concrete(path: str) -> str: import re out = path for key, value in _PATH_SUBS.items(): out = out.replace(key, value) # Any remaining {param} tokens from the Broadcom universe. 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]: url = f"{BASE}{_concrete(path)}" req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, context=_ctx()) 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: 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 SystemExit(f"session failed: {code} {body[:200]}") return json.loads(body) def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]: """Return (url_suffix_or_path, body_bytes). Path may gain query string.""" 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 SystemExit(f"contract apply major={major} failed: {code} {body[:200]}") return json.loads(body) 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() failures: list[dict[str, Any]] = [] probed = 0 for entry in entries: verb = entry["verb"] path = entry["path"] # Don't tear down the probe session mid-run. if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}: continue # Don't destroy seeded datacenter/cluster/folder parents. 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}", }: # Still hit the route, but against missing id → expect 4xx. 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 if 200 <= code < 300: buckets["success_2xx"] += 1 # Major 9 must return real seeded payloads, not synthetic stub markers. if major == 9 and verb == "GET" and body: if '"stub": true' in body or '"stub":true' in body: buckets["stub_marker"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "status": code, "body": body[:200], "expected": "non-stub JSON from DB/inventory", } ) elif path in { "/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", }: try: parsed = json.loads(body) except json.JSONDecodeError: parsed = None empty = parsed in ([], {}, None) or parsed == "" if empty or (isinstance(parsed, dict) and parsed == {}): buckets["empty_inventory"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "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, "status": code, "body": body[:200]} ) elif code >= 500: buckets["server_5xx"] += 1 failures.append( {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]} ) else: buckets[f"other_{code}"] += 1 failures.append( {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]} ) # Paths above this major's catalog floor still must serve real data (no 501). 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 if code == 501: buckets["unexpected_501"] += 1 failures.append( { "major": major, "verb": verb, "path": path, "status": code, "body": body[:200], "expected": "2xx/4xx (version gate disabled)", } ) elif 200 <= code < 300: buckets["success_2xx"] += 1 elif code in _ACCEPT_CLIENT: buckets["client_4xx"] += 1 elif code >= 500: buckets["server_5xx"] += 1 failures.append( {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]} ) else: buckets[f"other_{code}"] += 1 failures.append( {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]} ) by_verb = Counter(e["verb"] for e in entries) 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), "failures": failures, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--majors", default="6,7,8,9", help="Comma-separated majors") args = parser.parse_args() majors = [int(x) for x in args.majors.split(",") if x.strip()] for major in majors: if major not in VERSIONS: raise SystemExit(f"unknown major {major}") session = _login() reports = [] all_failures: list[dict[str, Any]] = [] for major in majors: report = probe_major(major, session) reports.append(report) all_failures.extend(report["failures"]) # Refresh session between majors (logout delete skipped during probe). session = _login() # Restore latest floor for the lab UI. _apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"}) summary = { "base": BASE, "majors": reports, "total_failures": len(all_failures), "failures": all_failures[:80], } print(json.dumps(summary, indent=2)) return 1 if all_failures else 0 if __name__ == "__main__": raise SystemExit(main())