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.
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user