Files
vmware-api-simulator/scripts/vsphere_full_matrix_probe.py
T
inecs f8d3cbdd59 Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
2026-07-18 04:42:11 +03:00

440 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.
#!/usr/bin/env python3
"""Probe every registered vSphere REST method for majors 69 (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())