516 lines
18 KiB
Python
516 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a compact vSphere REST param index from official Automation OpenAPI.
|
|
|
|
Source (default):
|
|
https://raw.githubusercontent.com/vmware/vcf-api-specs/main/specifications/vsphere/openapi/automation/vcenter.yaml
|
|
|
|
Output:
|
|
app/vsphere/rest/param_index.json
|
|
|
|
Keys are ``VERB /api/...`` matching the simulator catalog (OpenAPI servers already
|
|
use ``/api`` as the base). Paths that OpenAPI encodes as
|
|
``/vcenter/vm/{vm}/power?action=start`` are collapsed onto the template path with
|
|
an ``action`` query field (enum of all observed actions).
|
|
|
|
Requires PyYAML at generation time only (not a runtime dependency).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from app.vsphere.rest.param_fields import body_fields_from_example # noqa: E402
|
|
|
|
DEFAULT_URL = (
|
|
"https://raw.githubusercontent.com/vmware/vcf-api-specs/main/"
|
|
"specifications/vsphere/openapi/automation/vcenter.yaml"
|
|
)
|
|
OUT = ROOT / "app" / "vsphere" / "rest" / "param_index.json"
|
|
|
|
_PATH_QUERY = re.compile(r"^(?P<path>[^?]+)(?:\?(?P<query>.*))?$")
|
|
_ENUM_CAP = 24
|
|
|
|
# Prefer lab seed identifiers when property names match.
|
|
# Filter examples must AND-match on every profile (small / large / demo-cluster):
|
|
# web-01 (vm-101) is always POWERED_ON on host-11.
|
|
_LAB_EXAMPLES: dict[str, Any] = {
|
|
"vm": "vm-101",
|
|
"vms": ["vm-101"],
|
|
"name": "web-01",
|
|
"names": ["web-01"],
|
|
"host": "host-11",
|
|
"hosts": ["host-11"],
|
|
"folder": "group-v23",
|
|
"folders": ["group-v23"],
|
|
"datastore": "datastore-31",
|
|
"datastores": ["datastore-31"],
|
|
"datacenter": "datacenter-21",
|
|
"datacenters": ["datacenter-21"],
|
|
"cluster": "domain-c21",
|
|
"clusters": ["domain-c21"],
|
|
"resource_pool": "resgroup-22",
|
|
"resource_pools": ["resgroup-22"],
|
|
"network": "network-41",
|
|
"networks": ["network-41"],
|
|
"power_states": ["POWERED_ON"],
|
|
"guest_os": "OTHER_GUEST_64",
|
|
"guest_OS": "OTHER_GUEST_64",
|
|
"action": "start",
|
|
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
|
|
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
|
|
"item_id": "item-demo",
|
|
"library_id": "library-demo",
|
|
"task": "task-1",
|
|
"snapshot": "snapshot-1",
|
|
"count": 2,
|
|
"size_mib": 2048,
|
|
"size_MiB": 2048,
|
|
"memory_size_MiB": 2048,
|
|
"cpu_count": 2,
|
|
}
|
|
|
|
|
|
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
try:
|
|
import yaml
|
|
except ImportError as exc: # pragma: no cover
|
|
raise SystemExit("PyYAML is required to generate the param index") from exc
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise SystemExit("OpenAPI root must be a mapping")
|
|
return data
|
|
|
|
|
|
def _fetch(url: str, dest: Path) -> None:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
print(f"Fetching {url}", file=sys.stderr)
|
|
with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310
|
|
dest.write_bytes(resp.read())
|
|
print(f"Wrote {dest} ({dest.stat().st_size} bytes)", file=sys.stderr)
|
|
|
|
|
|
def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
|
|
if not ref.startswith("#/"):
|
|
return {}
|
|
node: Any = spec
|
|
for part in ref[2:].split("/"):
|
|
if not isinstance(node, dict):
|
|
return {}
|
|
node = node.get(part)
|
|
return node if isinstance(node, dict) else {}
|
|
|
|
|
|
def _deref(spec: dict[str, Any], schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not schema:
|
|
return {}
|
|
if "$ref" in schema:
|
|
resolved = _resolve_ref(spec, str(schema["$ref"]))
|
|
merged = dict(resolved)
|
|
for key, value in schema.items():
|
|
if key != "$ref":
|
|
merged[key] = value
|
|
return _deref(spec, merged) if ("$ref" in merged or "allOf" in merged) else merged
|
|
if "allOf" in schema and isinstance(schema["allOf"], list):
|
|
merged: dict[str, Any] = {}
|
|
props: dict[str, Any] = {}
|
|
required: list[str] = []
|
|
for part in schema["allOf"]:
|
|
if not isinstance(part, dict):
|
|
continue
|
|
resolved = _deref(spec, part)
|
|
for key, value in resolved.items():
|
|
if key == "properties" and isinstance(value, dict):
|
|
props.update(value)
|
|
elif key == "required" and isinstance(value, list):
|
|
required.extend(str(item) for item in value)
|
|
elif key not in {"properties", "required"}:
|
|
merged[key] = value
|
|
for key, value in schema.items():
|
|
if key == "allOf":
|
|
continue
|
|
if key == "properties" and isinstance(value, dict):
|
|
props.update(value)
|
|
elif key == "required" and isinstance(value, list):
|
|
required.extend(str(item) for item in value)
|
|
else:
|
|
merged[key] = value
|
|
if props:
|
|
merged["properties"] = props
|
|
if required:
|
|
merged["required"] = list(dict.fromkeys(required))
|
|
if "type" not in merged and props:
|
|
merged["type"] = "object"
|
|
return merged
|
|
return schema
|
|
|
|
|
|
def _short_desc(text: Any) -> str | None:
|
|
if not isinstance(text, str) or not text.strip():
|
|
return None
|
|
line = text.strip().split("\n", 1)[0].strip()
|
|
if len(line) > 160:
|
|
return line[:157] + "..."
|
|
return line
|
|
|
|
|
|
def _example_for(
|
|
spec: dict[str, Any],
|
|
name: str,
|
|
schema: dict[str, Any],
|
|
*,
|
|
depth: int = 0,
|
|
) -> Any:
|
|
schema = _deref(spec, schema)
|
|
if name in _LAB_EXAMPLES:
|
|
return _LAB_EXAMPLES[name]
|
|
if "example" in schema:
|
|
return schema["example"]
|
|
if "default" in schema:
|
|
return schema["default"]
|
|
enum = schema.get("enum")
|
|
if isinstance(enum, list) and enum:
|
|
return enum[0]
|
|
typ = schema.get("type")
|
|
if typ == "array":
|
|
items = schema.get("items") if isinstance(schema.get("items"), dict) else {}
|
|
item_ex = _example_for(spec, name.rstrip("s") or name, items, depth=depth + 1)
|
|
return [item_ex] if item_ex is not None else []
|
|
if typ == "object" or "properties" in schema:
|
|
if depth >= 3:
|
|
return {}
|
|
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
req = set(schema.get("required") or [])
|
|
# Prefer required props; include a few well-known optional lab fields.
|
|
keys = list(req)
|
|
for extra in ("name", "placement", "cpu", "memory", "description", "spec", "create_spec"):
|
|
if extra in props and extra not in keys:
|
|
keys.append(extra)
|
|
if not keys:
|
|
keys = list(props.keys())[:6]
|
|
out: dict[str, Any] = {}
|
|
for key in keys:
|
|
prop = props.get(key)
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
out[key] = _example_for(spec, key, prop, depth=depth + 1)
|
|
return out
|
|
if typ == "boolean":
|
|
return False
|
|
if typ == "integer":
|
|
return int(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1
|
|
if typ == "number":
|
|
return float(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1.0
|
|
if typ == "string" or typ is None:
|
|
return "example"
|
|
return None
|
|
|
|
|
|
def _field_from_schema(
|
|
spec: dict[str, Any],
|
|
name: str,
|
|
schema: dict[str, Any],
|
|
*,
|
|
optional: bool,
|
|
description: str | None = None,
|
|
) -> dict[str, Any]:
|
|
schema = _deref(spec, schema)
|
|
typ = schema.get("type")
|
|
if typ is None and "properties" in schema:
|
|
typ = "object"
|
|
if typ is None and "items" in schema:
|
|
typ = "array"
|
|
enum = schema.get("enum") if isinstance(schema.get("enum"), list) else []
|
|
if len(enum) > _ENUM_CAP:
|
|
enum = enum[:_ENUM_CAP]
|
|
example = _example_for(spec, name, schema)
|
|
return {
|
|
"name": name,
|
|
"type": typ or "string",
|
|
"optional": optional,
|
|
"description": _short_desc(description or schema.get("description")),
|
|
"enum": enum,
|
|
"example": example,
|
|
}
|
|
|
|
|
|
def _collect_params(
|
|
spec: dict[str, Any],
|
|
operation: dict[str, Any],
|
|
path_item_params: list[dict[str, Any]],
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
query: list[dict[str, Any]] = []
|
|
path: list[dict[str, Any]] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for raw in list(path_item_params) + list(operation.get("parameters") or []):
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
param = _deref(spec, raw) if "$ref" in raw else raw
|
|
name = str(param.get("name") or "")
|
|
location = str(param.get("in") or "")
|
|
if not name or location not in {"query", "path"}:
|
|
continue
|
|
key = (location, name)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
schema = param.get("schema") if isinstance(param.get("schema"), dict) else {}
|
|
field = _field_from_schema(
|
|
spec,
|
|
name,
|
|
schema,
|
|
optional=not bool(param.get("required")),
|
|
description=param.get("description") if isinstance(param.get("description"), str) else None,
|
|
)
|
|
if location == "query":
|
|
query.append(field)
|
|
else:
|
|
path.append(field)
|
|
return query, path
|
|
|
|
|
|
def _body_from_operation(
|
|
spec: dict[str, Any],
|
|
operation: dict[str, Any],
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
request_body = operation.get("requestBody")
|
|
if not isinstance(request_body, dict):
|
|
return [], {}
|
|
request_body = _deref(spec, request_body) if "$ref" in request_body else request_body
|
|
content = request_body.get("content") if isinstance(request_body.get("content"), dict) else {}
|
|
media = content.get("application/json") or next(iter(content.values()), None)
|
|
if not isinstance(media, dict):
|
|
return [], {}
|
|
schema = _deref(spec, media.get("schema") if isinstance(media.get("schema"), dict) else {})
|
|
if not schema:
|
|
return [], {}
|
|
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
if props:
|
|
example = _example_for(spec, "", schema)
|
|
example_dict = example if isinstance(example, dict) else {}
|
|
# PARAM drawer leaves come from nested body_example (placement.host, …).
|
|
return body_fields_from_example(example_dict), example_dict
|
|
# Body is a naked $ref / non-object — still emit an example blob.
|
|
example = _example_for(spec, "body", schema)
|
|
example_dict = example if isinstance(example, dict) else {}
|
|
return body_fields_from_example(example_dict), example_dict
|
|
|
|
|
|
def _normalize_path_key(raw_path: str) -> tuple[str, dict[str, str]]:
|
|
"""Return (/api/... template, query extras from path key like action=start)."""
|
|
match = _PATH_QUERY.match(raw_path)
|
|
if not match:
|
|
return raw_path, {}
|
|
path = match.group("path")
|
|
query_raw = match.group("query") or ""
|
|
extras: dict[str, str] = {}
|
|
if query_raw:
|
|
for part in query_raw.split("&"):
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
extras[key] = value
|
|
elif part:
|
|
extras[part] = ""
|
|
if not path.startswith("/api/"):
|
|
path = "/api" + path if path.startswith("/") else "/api/" + path
|
|
return path, extras
|
|
|
|
|
|
def _merge_action_field(query_fields: list[dict[str, Any]], actions: set[str]) -> None:
|
|
if not actions:
|
|
return
|
|
existing = next((field for field in query_fields if field["name"] == "action"), None)
|
|
ordered = sorted(actions)
|
|
if existing is None:
|
|
query_fields.insert(
|
|
0,
|
|
{
|
|
"name": "action",
|
|
"type": "string",
|
|
"optional": False,
|
|
"description": "Operation action query parameter",
|
|
"enum": ordered,
|
|
"example": ordered[0],
|
|
},
|
|
)
|
|
return
|
|
enum = list(dict.fromkeys([*list(existing.get("enum") or []), *ordered]))
|
|
existing["enum"] = enum
|
|
existing["optional"] = False
|
|
if not existing.get("example"):
|
|
existing["example"] = enum[0]
|
|
|
|
|
|
def build_index(spec: dict[str, Any]) -> dict[str, Any]:
|
|
paths = spec.get("paths") if isinstance(spec.get("paths"), dict) else {}
|
|
# Accumulate by VERB + template path so ?action= variants collapse.
|
|
buckets: dict[str, dict[str, Any]] = {}
|
|
|
|
def ensure_bucket(verb: str, template: str) -> dict[str, Any]:
|
|
key = f"{verb} {template}"
|
|
return buckets.setdefault(
|
|
key,
|
|
{
|
|
"verb": verb,
|
|
"path": template,
|
|
"query_fields": [],
|
|
"path_fields": [],
|
|
"body_fields": [],
|
|
"body_example": {},
|
|
"operation_ids": [],
|
|
"actions": set(),
|
|
},
|
|
)
|
|
|
|
def merge_operation(
|
|
*,
|
|
verb: str,
|
|
template: str,
|
|
operation: dict[str, Any],
|
|
path_params: list[dict[str, Any]],
|
|
action: str | None = None,
|
|
) -> None:
|
|
key = f"{verb} {template}"
|
|
if action:
|
|
existing = buckets.get(key)
|
|
# Do not pollute CreateSpec-style POSTs with clone/register actions.
|
|
if existing is not None and (existing["body_fields"] or existing["body_example"]):
|
|
return
|
|
bucket = ensure_bucket(verb, template)
|
|
bucket["actions"].add(action)
|
|
_query, path_fields = _collect_params(spec, operation, path_params)
|
|
for field in path_fields:
|
|
names = {item["name"] for item in bucket["path_fields"]}
|
|
if field["name"] not in names:
|
|
bucket["path_fields"].append(field)
|
|
op_id = operation.get("operationId")
|
|
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
|
|
bucket["operation_ids"].append(op_id)
|
|
return
|
|
|
|
bucket = ensure_bucket(verb, template)
|
|
query_fields, path_fields = _collect_params(spec, operation, path_params)
|
|
body_fields, body_example = _body_from_operation(spec, operation)
|
|
for field in query_fields:
|
|
names = {item["name"] for item in bucket["query_fields"]}
|
|
if field["name"] not in names:
|
|
bucket["query_fields"].append(field)
|
|
for field in path_fields:
|
|
names = {item["name"] for item in bucket["path_fields"]}
|
|
if field["name"] not in names:
|
|
bucket["path_fields"].append(field)
|
|
if body_fields and not bucket["body_fields"]:
|
|
bucket["body_fields"] = body_fields
|
|
if body_example and not bucket["body_example"]:
|
|
bucket["body_example"] = body_example
|
|
op_id = operation.get("operationId")
|
|
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
|
|
bucket["operation_ids"].append(op_id)
|
|
|
|
# Pass 1: concrete paths without ?query — establish CreateSpec etc.
|
|
# Pass 2: ?action= variants — attach only onto action-style endpoints.
|
|
ordered_paths = sorted(paths.items(), key=lambda item: ("?" in str(item[0]), str(item[0])))
|
|
for raw_path, path_item in ordered_paths:
|
|
if not isinstance(path_item, dict):
|
|
continue
|
|
template, extras = _normalize_path_key(str(raw_path))
|
|
path_params = [p for p in (path_item.get("parameters") or []) if isinstance(p, dict)]
|
|
for verb, operation in path_item.items():
|
|
if verb.startswith("x-") or verb == "parameters" or not isinstance(operation, dict):
|
|
continue
|
|
upper = verb.upper()
|
|
if upper not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
|
|
continue
|
|
merge_operation(
|
|
verb=upper,
|
|
template=template,
|
|
operation=operation,
|
|
path_params=path_params,
|
|
action=extras.get("action") or None,
|
|
)
|
|
|
|
methods: dict[str, Any] = {}
|
|
for key, bucket in sorted(buckets.items()):
|
|
actions: set[str] = bucket.pop("actions")
|
|
_merge_action_field(bucket["query_fields"], actions)
|
|
entry = {
|
|
"verb": bucket["verb"],
|
|
"path": bucket["path"],
|
|
"query_fields": bucket["query_fields"],
|
|
"path_fields": bucket["path_fields"],
|
|
"body_fields": bucket["body_fields"],
|
|
"body_example": bucket["body_example"],
|
|
"operation_ids": bucket["operation_ids"],
|
|
}
|
|
methods[key] = entry
|
|
|
|
info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
|
|
return {
|
|
"source": "vmware/vcf-api-specs specifications/vsphere/openapi/automation/vcenter.yaml",
|
|
"openapi": spec.get("openapi"),
|
|
"title": info.get("title"),
|
|
"version": info.get("version"),
|
|
"method_count": len(methods),
|
|
"methods": methods,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--url", default=DEFAULT_URL, help="OpenAPI YAML URL")
|
|
parser.add_argument(
|
|
"--input",
|
|
type=Path,
|
|
help="Local OpenAPI YAML (skips download)",
|
|
)
|
|
parser.add_argument(
|
|
"--cache",
|
|
type=Path,
|
|
default=ROOT / ".cache" / "vcenter.openapi.yaml",
|
|
help="Download cache path",
|
|
)
|
|
parser.add_argument("--output", type=Path, default=OUT)
|
|
args = parser.parse_args()
|
|
|
|
if args.input is not None:
|
|
source = args.input
|
|
else:
|
|
if not args.cache.exists():
|
|
_fetch(args.url, args.cache)
|
|
source = args.cache
|
|
|
|
spec = _load_yaml(source)
|
|
index = build_index(spec)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
print(
|
|
f"Wrote {args.output} methods={index['method_count']} version={index.get('version')}",
|
|
file=sys.stderr,
|
|
)
|
|
# Spot-check critical routes.
|
|
for probe in ("GET /api/vcenter/vm", "POST /api/vcenter/vm", "POST /api/vcenter/vm/{vm}/power"):
|
|
entry = index["methods"].get(probe)
|
|
if not entry:
|
|
print(f"WARN missing {probe}", file=sys.stderr)
|
|
continue
|
|
print(
|
|
f"OK {probe} query={len(entry['query_fields'])} "
|
|
f"body_fields={len(entry['body_fields'])} "
|
|
f"body_example_keys={list(entry['body_example'])[:6]}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|