f8d3cbdd59
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.
254 lines
7.6 KiB
Python
254 lines
7.6 KiB
Python
#!/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())
|