Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.
This commit is contained in:
+109
-148
@@ -1,8 +1,19 @@
|
||||
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
|
||||
"""Native vSphere API catalog for the Web UI console.
|
||||
|
||||
Parameter / request-body metadata comes from the official Automation OpenAPI
|
||||
(``app/vsphere/rest/param_index.json``, generated by
|
||||
``scripts/generate_vsphere_param_index.py``). Nested ``body_example`` values are
|
||||
flattened into dotted PARAM leaves (``placement.host``, ``cpu.count``, …).
|
||||
Path-parameter examples still use lab seed identifiers so Send works against
|
||||
the seeded inventory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.vsphere.contracts.matrix import (
|
||||
@@ -11,11 +22,13 @@ from app.vsphere.contracts.matrix import (
|
||||
is_implemented_for_major,
|
||||
load_bundle,
|
||||
)
|
||||
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
|
||||
_PARAM_INDEX_PATH = Path(__file__).resolve().parents[1] / "rest" / "param_index.json"
|
||||
|
||||
_PATH_EXAMPLES: dict[str, str] = {
|
||||
"vm": "vm-111",
|
||||
"vm": "vm-101",
|
||||
"host": "host-11",
|
||||
"datastore": "datastore-31",
|
||||
"task": "task-1",
|
||||
@@ -29,113 +42,17 @@ _PATH_EXAMPLES: dict[str, str] = {
|
||||
"resource_pool": "resgroup-22",
|
||||
"permission_id": "1",
|
||||
"policy": "policy-default",
|
||||
"library_id": "library-demo",
|
||||
}
|
||||
|
||||
# Common query/body fields for lab Params drawer (not a full OpenAPI schema).
|
||||
_QUERY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
|
||||
("GET", "/api/vcenter/vm"): [
|
||||
{
|
||||
"name": "names",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "app-0011",
|
||||
"description": "Filter by VM name",
|
||||
},
|
||||
{
|
||||
"name": "hosts",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "host-11",
|
||||
"description": "Filter by host",
|
||||
},
|
||||
{
|
||||
"name": "power_states",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "POWERED_ON",
|
||||
"description": "Filter by power state",
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/vm/{vm}/power"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "start",
|
||||
"description": "start|stop|reset|suspend",
|
||||
"enum": ["start", "stop", "reset", "suspend"],
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/folder/{folder}"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "rename",
|
||||
"description": "rename|move",
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/host/{host}/maintenance"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "enter",
|
||||
"description": "enter|exit",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_BODY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
|
||||
("POST", "/api/vcenter/vm"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "lab-vm"},
|
||||
{
|
||||
"name": "placement",
|
||||
"type": "object",
|
||||
"optional": True,
|
||||
"example": '{"folder":"group-v23","host":"host-11","datastore":"datastore-31"}',
|
||||
},
|
||||
{"name": "cpu_count", "type": "integer", "optional": True, "example": "2"},
|
||||
{"name": "memory_size_MiB", "type": "integer", "optional": True, "example": "2048"},
|
||||
],
|
||||
("POST", "/api/vcenter/datacenter"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "Datacenter-2"},
|
||||
{"name": "folder", "type": "string", "optional": True, "example": "group-d1"},
|
||||
],
|
||||
("POST", "/api/vcenter/cluster"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "Cluster-2"},
|
||||
{"name": "folder", "type": "string", "optional": True, "example": "group-h23"},
|
||||
],
|
||||
("POST", "/api/vcenter/folder"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "workloads"},
|
||||
{"name": "parent", "type": "string", "optional": True, "example": "group-v23"},
|
||||
{"name": "type", "type": "string", "optional": True, "example": "VIRTUAL_MACHINE"},
|
||||
],
|
||||
("POST", "/api/cis/tagging/category"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"env","description":"lab","cardinality":"MULTIPLE","associable_types":[]}',
|
||||
},
|
||||
],
|
||||
("POST", "/api/cis/tagging/tag"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"prod","category_id":"…"}',
|
||||
},
|
||||
],
|
||||
("POST", "/api/content/local-library"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"Templates"}',
|
||||
},
|
||||
],
|
||||
}
|
||||
@lru_cache(maxsize=1)
|
||||
def _param_index() -> dict[str, Any]:
|
||||
if not _PARAM_INDEX_PATH.is_file():
|
||||
return {"methods": {}}
|
||||
payload = json.loads(_PARAM_INDEX_PATH.read_text(encoding="utf-8"))
|
||||
methods = payload.get("methods")
|
||||
return methods if isinstance(methods, dict) else {}
|
||||
|
||||
|
||||
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
|
||||
@@ -230,23 +147,32 @@ def _path_fields(path: str) -> list[dict[str, Any]]:
|
||||
return fields
|
||||
|
||||
|
||||
def _body_example_from_fields(fields: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
for field in fields:
|
||||
if field.get("optional"):
|
||||
def _normalize_index_fields(raw_fields: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw_fields, list):
|
||||
return []
|
||||
fields: list[dict[str, Any]] = []
|
||||
for item in raw_fields:
|
||||
if not isinstance(item, dict) or not item.get("name"):
|
||||
continue
|
||||
example = field.get("example")
|
||||
if isinstance(example, str) and example.startswith("{"):
|
||||
try:
|
||||
import json
|
||||
enum = item.get("enum") if isinstance(item.get("enum"), list) else []
|
||||
fields.append(
|
||||
_field(
|
||||
str(item["name"]),
|
||||
type_name=str(item.get("type") or "string"),
|
||||
optional=bool(item.get("optional", True)),
|
||||
example=item.get("example"),
|
||||
description=item.get("description") if isinstance(item.get("description"), str) else None,
|
||||
enum=[str(value) for value in enum],
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
body[field["name"]] = json.loads(example)
|
||||
continue
|
||||
except Exception:
|
||||
body[field["name"]] = example
|
||||
continue
|
||||
body[field["name"]] = example
|
||||
return body
|
||||
|
||||
def _lookup_param_entry(verb: str, path: str) -> dict[str, Any] | None:
|
||||
methods = _param_index()
|
||||
key = f"{verb.upper()} {path}"
|
||||
entry = methods.get(key)
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def vsphere_method_payload(
|
||||
@@ -259,31 +185,64 @@ def vsphere_method_payload(
|
||||
meta = VERSIONS.get(major) or VERSIONS[9]
|
||||
upper = verb.upper()
|
||||
path_fields = _path_fields(path)
|
||||
key = (upper, path)
|
||||
query_or_body = _QUERY_FIELDS.get(key, [])
|
||||
body_fields = list(_BODY_FIELDS.get(key, []))
|
||||
# Query-style action fields appear as body_fields in the Params UI (same editor).
|
||||
for item in query_or_body:
|
||||
body_fields.append(
|
||||
_field(
|
||||
str(item["name"]),
|
||||
type_name=str(item.get("type") or "string"),
|
||||
optional=bool(item.get("optional", True)),
|
||||
example=item.get("example"),
|
||||
description=item.get("description"),
|
||||
enum=list(item.get("enum") or []),
|
||||
)
|
||||
)
|
||||
# Generic POST with {path params} but no body schema → offer empty object note via name.
|
||||
if upper in {"POST", "PATCH", "PUT"} and not body_fields and "{" not in path:
|
||||
body_fields.append(
|
||||
_field(
|
||||
"name",
|
||||
optional=True,
|
||||
example="example",
|
||||
description="Primary name field when required by create APIs",
|
||||
)
|
||||
)
|
||||
entry = _lookup_param_entry(upper, path)
|
||||
|
||||
query_fields: list[dict[str, Any]] = []
|
||||
body_fields: list[dict[str, Any]] = []
|
||||
body_example: dict[str, Any] = {}
|
||||
|
||||
if entry is not None:
|
||||
# Prefer OpenAPI path examples when present, but keep lab seed IDs.
|
||||
indexed_path = _normalize_index_fields(entry.get("path_fields"))
|
||||
if indexed_path:
|
||||
by_name = {field["name"]: field for field in indexed_path}
|
||||
merged_path: list[dict[str, Any]] = []
|
||||
for field in path_fields:
|
||||
indexed = by_name.get(str(field["name"]))
|
||||
if indexed is None:
|
||||
merged_path.append(field)
|
||||
continue
|
||||
merged = dict(indexed)
|
||||
# Lab seed identifiers beat generic OpenAPI "example" strings.
|
||||
if field["name"] in _PATH_EXAMPLES:
|
||||
merged["example"] = _PATH_EXAMPLES[str(field["name"])]
|
||||
merged_path.append(merged)
|
||||
path_fields = merged_path
|
||||
query_fields = _normalize_index_fields(entry.get("query_fields"))
|
||||
body_fields = _normalize_index_fields(entry.get("body_fields"))
|
||||
raw_example = entry.get("body_example")
|
||||
if isinstance(raw_example, dict):
|
||||
body_example = raw_example
|
||||
|
||||
# Prefer leaf paths flattened from nested body_example (placement.host, …).
|
||||
nested_fields = body_fields_from_example(body_example)
|
||||
if nested_fields:
|
||||
body_fields = nested_fields
|
||||
elif not body_example and body_fields:
|
||||
# Build a nested example from dotted / JSON-string body fields.
|
||||
built: dict[str, Any] = {}
|
||||
for field in body_fields:
|
||||
if field.get("optional"):
|
||||
continue
|
||||
example = field.get("example")
|
||||
name = str(field["name"])
|
||||
if isinstance(example, str) and example[:1] in {"{", "["}:
|
||||
try:
|
||||
example = json.loads(example)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if "." in name:
|
||||
set_by_path(built, name, example)
|
||||
else:
|
||||
built[name] = example
|
||||
body_example = built
|
||||
nested_fields = body_fields_from_example(body_example)
|
||||
if nested_fields:
|
||||
body_fields = nested_fields
|
||||
|
||||
# Params drawer shows query + body together; keep query_fields distinct for URL build.
|
||||
params_fields = [*body_fields, *query_fields]
|
||||
|
||||
resolved = path
|
||||
for field in path_fields:
|
||||
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
|
||||
@@ -295,10 +254,12 @@ def vsphere_method_payload(
|
||||
"description": f"{upper} {path}",
|
||||
"resolved_path": resolved,
|
||||
"path_fields": path_fields,
|
||||
"body_fields": body_fields,
|
||||
"query_fields": query_fields,
|
||||
"body_fields": params_fields,
|
||||
"indexed_fields": [],
|
||||
"body_example": _body_example_from_fields(body_fields),
|
||||
"body_example": body_example,
|
||||
"implemented": is_implemented_for_major(upper, path, major),
|
||||
"runtime_version": runtime_version or meta["version"],
|
||||
"source_version": meta["version"],
|
||||
"param_source": "openapi" if entry is not None else "none",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user