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,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate REST universe stubs from Broadcom vSphere Automation operations index.
|
||||
|
||||
Source: contracts/vsphere/broadcom-9.1-operations-index.txt
|
||||
(scraped from https://developer.broadcom.com/xapis/vsphere-automation-api/latest/operation-index/)
|
||||
|
||||
Output: app/vsphere/rest/universe.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX = ROOT / "contracts" / "vsphere" / "broadcom-9.1-operations-index.txt"
|
||||
OUT = ROOT / "app" / "vsphere" / "rest" / "universe.json"
|
||||
|
||||
# Title-Case service tokens that already imply a hyphenated REST segment.
|
||||
_SERVICE_ALIAS: dict[tuple[str, ...], tuple[str, ...]] = {
|
||||
("Cis", "Session"): ("session",),
|
||||
("Content", "LocalLibrary"): ("content", "local-library"),
|
||||
("Content", "SubscribedLibrary"): ("content", "subscribed-library"),
|
||||
("Vcenter", "VM"): ("vcenter", "vm"),
|
||||
("Vcenter", "ResourcePool"): ("vcenter", "resource-pool"),
|
||||
("Vcenter", "Authorization", "Privileges"): ("vcenter", "privilege"),
|
||||
("Appliance", "Timesync"): ("appliance", "timesync"),
|
||||
}
|
||||
|
||||
# Segments that carry a resource id when used as a parent, or as a collection leaf.
|
||||
_ID_NAMES: dict[str, str] = {
|
||||
"vm": "vm",
|
||||
"host": "host",
|
||||
"hosts": "host",
|
||||
"cluster": "cluster",
|
||||
"clusters": "cluster",
|
||||
"datacenter": "datacenter",
|
||||
"datacenters": "datacenter",
|
||||
"folder": "folder",
|
||||
"folders": "folder",
|
||||
"datastore": "datastore",
|
||||
"datastores": "datastore",
|
||||
"network": "network",
|
||||
"networks": "network",
|
||||
"resource-pool": "resource_pool",
|
||||
"library": "library_id",
|
||||
"libraries": "library_id",
|
||||
"local-library": "library_id",
|
||||
"subscribed-library": "library_id",
|
||||
"item": "item_id",
|
||||
"items": "item_id",
|
||||
"category": "category_id",
|
||||
"categories": "category_id",
|
||||
"tag": "tag_id",
|
||||
"tags": "tag_id",
|
||||
"policy": "policy",
|
||||
"policies": "policy",
|
||||
"snapshot": "snapshot",
|
||||
"snapshots": "snapshot",
|
||||
"disk": "disk",
|
||||
"disks": "disk",
|
||||
"ethernet": "nic",
|
||||
"cdrom": "cdrom",
|
||||
"cdroms": "cdrom",
|
||||
"serial": "port",
|
||||
"parallel": "port",
|
||||
"floppy": "floppy",
|
||||
"nvme": "adapter",
|
||||
"sata": "adapter",
|
||||
"scsi": "adapter",
|
||||
"provider": "provider",
|
||||
"providers": "provider",
|
||||
"task": "task",
|
||||
"tasks": "task",
|
||||
"permission": "permission_id",
|
||||
"permissions": "permission_id",
|
||||
"role": "role",
|
||||
"roles": "role",
|
||||
"zone": "zone",
|
||||
"zones": "zone",
|
||||
"project": "project",
|
||||
"projects": "project",
|
||||
"domain": "domain",
|
||||
"domains": "domain",
|
||||
"service": "service",
|
||||
"services": "service",
|
||||
"supervisor": "supervisor",
|
||||
"supervisors": "supervisor",
|
||||
"namespace": "namespace",
|
||||
"namespaces": "namespace",
|
||||
"depot": "depot",
|
||||
"depots": "depot",
|
||||
"component": "component",
|
||||
"components": "component",
|
||||
"image": "image",
|
||||
"images": "image",
|
||||
"draft": "draft",
|
||||
"drafts": "draft",
|
||||
"connection": "connection",
|
||||
"connections": "connection",
|
||||
"vpc": "vpc",
|
||||
"vpcs": "vpc",
|
||||
"subnet": "subnet",
|
||||
"subnets": "subnet",
|
||||
"download-session": "download_session_id",
|
||||
"update-session": "update_session_id",
|
||||
"subscription": "subscription_id",
|
||||
"subscriptions": "subscription_id",
|
||||
"usage": "usage_id",
|
||||
"usages": "usage_id",
|
||||
"library-items": "item_id",
|
||||
"versions": "version",
|
||||
"check-outs": "vm",
|
||||
"trusted-root-chains": "chain",
|
||||
"nodes": "node",
|
||||
"profiles": "profile",
|
||||
"interfaces": "interface",
|
||||
"cores": "core",
|
||||
"commit": "commit",
|
||||
"commits": "commit",
|
||||
}
|
||||
_LEAF_ID_ACTIONS = {
|
||||
"get",
|
||||
"delete",
|
||||
"update",
|
||||
"set",
|
||||
"remove",
|
||||
"forceddelete",
|
||||
"forcedDelete",
|
||||
"forceDelete",
|
||||
}
|
||||
|
||||
|
||||
def _to_kebab(token: str) -> str:
|
||||
token = token.replace("_", "-")
|
||||
s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", token)
|
||||
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", s)
|
||||
return s.lower()
|
||||
|
||||
|
||||
def _parse_ops(text: str) -> list[tuple[str, tuple[str, ...], str]]:
|
||||
ops: list[tuple[str, tuple[str, ...], str]] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
match = re.match(r"^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$", line)
|
||||
if not match:
|
||||
continue
|
||||
method, rest = match.group(1), match.group(2)
|
||||
parts = rest.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
action = parts[-1]
|
||||
service = tuple(parts[:-1])
|
||||
ops.append((method, service, action))
|
||||
return ops
|
||||
|
||||
|
||||
def _segments(service: tuple[str, ...]) -> list[str]:
|
||||
if service in _SERVICE_ALIAS:
|
||||
return list(_SERVICE_ALIAS[service])
|
||||
# Prefer Vm over VM when both appear in aliases above.
|
||||
return [_to_kebab(part) for part in service]
|
||||
|
||||
|
||||
def _action_base(action: str) -> str:
|
||||
return action.split("$", 1)[0]
|
||||
|
||||
|
||||
def _build_path(service: tuple[str, ...], action: str, actions_for_service: set[str]) -> str:
|
||||
action_base = _action_base(action)
|
||||
segs = _segments(service)
|
||||
out: list[str] = []
|
||||
for index, seg in enumerate(segs):
|
||||
out.append(seg)
|
||||
id_name = _ID_NAMES.get(seg)
|
||||
if not id_name:
|
||||
continue
|
||||
is_last = index == len(segs) - 1
|
||||
if not is_last:
|
||||
out.append("{" + id_name + "}")
|
||||
continue
|
||||
leaf_actions = {_action_base(a).lower() for a in actions_for_service}
|
||||
collection = bool(leaf_actions & {"list", "create", "add"})
|
||||
if collection and action_base.lower() in {a.lower() for a in _LEAF_ID_ACTIONS}:
|
||||
out.append("{" + id_name + "}")
|
||||
return "/api/" + "/".join(out)
|
||||
|
||||
|
||||
def generate() -> dict:
|
||||
text = INDEX.read_text(encoding="utf-8")
|
||||
ops = _parse_ops(text)
|
||||
by_service: dict[tuple[str, ...], set[str]] = defaultdict(set)
|
||||
for _method, service, action in ops:
|
||||
by_service[service].add(action)
|
||||
|
||||
routes: dict[str, dict[str, str]] = {}
|
||||
# key: "VERB PATH"
|
||||
for method, service, action in ops:
|
||||
path = _build_path(service, action, by_service[service])
|
||||
key = f"{method} {path}"
|
||||
# Prefer keeping first-seen; all map to implemented stub.
|
||||
routes.setdefault(
|
||||
key,
|
||||
{
|
||||
"verb": method,
|
||||
"path": path,
|
||||
"service": " ".join(service),
|
||||
"sample_action": action,
|
||||
"status": "stub",
|
||||
},
|
||||
)
|
||||
|
||||
methods = sorted(routes.values(), key=lambda item: (item["path"], item["verb"]))
|
||||
verb_counts = Counter(item["verb"] for item in methods)
|
||||
payload = {
|
||||
"source": "https://developer.broadcom.com/xapis/vsphere-automation-api/latest/operation-index/",
|
||||
"source_label": "vSphere Automation API 9.1 (Latest) operations index",
|
||||
"source_file": str(INDEX.relative_to(ROOT)),
|
||||
"broadcom_operations": len(ops),
|
||||
"broadcom_by_verb": dict(Counter(m for m, _s, _a in ops)),
|
||||
"unique_routes": len(methods),
|
||||
"unique_by_verb": dict(verb_counts),
|
||||
"methods": methods,
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not INDEX.is_file():
|
||||
print(f"missing index: {INDEX}", file=sys.stderr)
|
||||
return 1
|
||||
payload = generate()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"out": str(OUT.relative_to(ROOT)),
|
||||
"broadcom_operations": payload["broadcom_operations"],
|
||||
"unique_routes": payload["unique_routes"],
|
||||
"unique_by_verb": payload["unique_by_verb"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user