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,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe every implemented REST path from the coverage registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
|
||||
from app.vsphere.rest.coverage import catalog_entries
|
||||
|
||||
BASE = os.getenv("VSPHERE_BASE", "https://localhost")
|
||||
USER = "administrator@vsphere.local"
|
||||
PASSWORD = "VMware1!"
|
||||
|
||||
|
||||
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
|
||||
|
||||
subs = {
|
||||
"{vm}": "vm-101",
|
||||
"{host}": "host-11",
|
||||
"{datastore}": "datastore-31",
|
||||
"{task}": "task-missing",
|
||||
"{snapshot}": "snapshot-missing",
|
||||
"{category_id}": "missing",
|
||||
"{tag_id}": "missing",
|
||||
"{item_id}": "missing",
|
||||
"{library_id}": "lib-missing",
|
||||
"{folder}": "group-v23",
|
||||
"{datacenter}": "datacenter-21",
|
||||
"{cluster}": "domain-c21",
|
||||
"{resource_pool}": "resgroup-22",
|
||||
"{permission_id}": "1",
|
||||
"{policy}": "policy-default",
|
||||
"{disk}": "2000",
|
||||
"{nic}": "4000",
|
||||
"{cdrom}": "3000",
|
||||
"{adapter}": "1000",
|
||||
"{network}": "network-41",
|
||||
}
|
||||
out = path
|
||||
for key, value in 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) -> int:
|
||||
concrete = _concrete(path)
|
||||
req = urllib.request.Request(f"{BASE}{concrete}", data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_ctx()) as resp: # noqa: S310
|
||||
return int(resp.status)
|
||||
except urllib.error.HTTPError as error:
|
||||
return int(error.code)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
basic = b64encode(f"{USER}:{PASSWORD}".encode()).decode()
|
||||
status = _request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
|
||||
if status not in {200, 201}:
|
||||
print(f"session failed: {status}", file=sys.stderr)
|
||||
return 1
|
||||
# Re-login to capture body
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/api/session",
|
||||
method="POST",
|
||||
headers={"Authorization": f"Basic {basic}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, context=_ctx()) as resp: # noqa: S310
|
||||
session = json.loads(resp.read().decode())
|
||||
headers = {"vmware-api-session-id": session, "Content-Type": "application/json"}
|
||||
failures: list[str] = []
|
||||
probed = 0
|
||||
for entry in catalog_entries():
|
||||
verb = entry["verb"]
|
||||
path = entry["path"]
|
||||
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
|
||||
continue
|
||||
if (
|
||||
"{" in path
|
||||
and verb in {"POST", "PATCH", "DELETE"}
|
||||
and "missing" in (path.replace("{vm}", "vm-101"))
|
||||
):
|
||||
# skip destructive ops on missing ids except GET
|
||||
pass
|
||||
data = b"{}" if verb in {"POST", "PUT", "PATCH"} else None
|
||||
if path.endswith("/power") and verb == "POST":
|
||||
code = _request(verb, path + "?action=start", headers=headers)
|
||||
elif "tag-association" in path and verb == "POST":
|
||||
data = json.dumps(
|
||||
{
|
||||
"action": "list-attached-tags",
|
||||
"tag_id": "x",
|
||||
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
|
||||
}
|
||||
).encode()
|
||||
code = _request(verb, path, headers=headers, data=data)
|
||||
else:
|
||||
code = _request(verb, path, headers=headers, data=data)
|
||||
probed += 1
|
||||
# Accept success, not-found for missing substitutions, or validation errors.
|
||||
if code >= 500:
|
||||
failures.append(f"{verb} {path} -> {code}")
|
||||
continue
|
||||
if verb == "GET" and 200 <= code < 300:
|
||||
# Surface probe reads body via a second request-sized check only for markers.
|
||||
# Re-fetch is avoided: empty GET bodies for session are OK.
|
||||
pass
|
||||
# Re-auth in case any probe request invalidated the session cookie.
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/api/session",
|
||||
method="POST",
|
||||
headers={"Authorization": f"Basic {basic}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, context=_ctx()) as resp: # noqa: S310
|
||||
session = json.loads(resp.read().decode())
|
||||
headers = {"vmware-api-session-id": session, "Content-Type": "application/json"}
|
||||
|
||||
# Spot-check critical inventory payloads are non-empty / non-stub.
|
||||
spot = [
|
||||
"/api/vcenter/vm",
|
||||
"/api/vcenter/host",
|
||||
"/api/content/library",
|
||||
"/api/cis/tagging/category",
|
||||
"/api/esx/settings/clusters/domain-c21/software",
|
||||
"/api/vcenter/namespace-management/supervisors/supervisor-1/summary",
|
||||
"/api/appliance/access/ssh",
|
||||
"/api/appliance/services",
|
||||
"/api/vcenter/vm/vm-101/hardware/cdrom",
|
||||
]
|
||||
for path in spot:
|
||||
url = f"{BASE}{_concrete(path)}"
|
||||
req = urllib.request.Request(url, method="GET", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_ctx()) as resp: # noqa: S310
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
code = int(resp.status)
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
code = int(error.code)
|
||||
if code >= 400:
|
||||
failures.append(f"GET {path} spot -> {code}")
|
||||
continue
|
||||
if '"stub": true' in body or '"stub":true' in body:
|
||||
failures.append(f"GET {path} spot -> stub marker")
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
failures.append(f"GET {path} spot -> non-json")
|
||||
continue
|
||||
if parsed in ([], {}, None):
|
||||
failures.append(f"GET {path} spot -> empty")
|
||||
print(json.dumps({"probed": probed, "failures": failures}, indent=2))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user