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:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Versioned vSphere REST coverage catalogs for the lab console."""
+304
View File
@@ -0,0 +1,304 @@
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
from __future__ import annotations
import re
from typing import Any
from app.vsphere.contracts.matrix import (
VERSIONS,
catalog_entries_for_major,
is_implemented_for_major,
load_bundle,
)
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
_PATH_EXAMPLES: dict[str, str] = {
"vm": "vm-111",
"host": "host-11",
"datastore": "datastore-31",
"task": "task-1",
"snapshot": "snapshot-1",
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
"item_id": "item-demo",
"folder": "group-v23",
"datacenter": "datacenter-21",
"cluster": "domain-c21",
"resource_pool": "resgroup-22",
"permission_id": "1",
"policy": "policy-default",
}
# 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"}',
},
],
}
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
return {
"runtime_version": runtime_version or VERSIONS[9]["version"],
"plane": "vsphere-rest",
"majors": [
{
"major": major,
"series": meta["series"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
}
for major, meta in VERSIONS.items()
],
}
def vsphere_catalog_payload(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
bundle = load_bundle(major)
entries = catalog_entries_for_major(major)
grouped: dict[str, dict[str, dict[str, Any]]] = {}
for entry in entries:
path = entry["path"]
parts = [p for p in path.split("/") if p]
tag = "/".join(parts[:3]) if len(parts) >= 3 else path
by_path = grouped.setdefault(tag, {})
path_entry = by_path.setdefault(path, {"path": path, "methods": []})
path_entry["methods"].append(
{
"verb": entry["verb"],
"name": f"{entry['verb'].lower()}_{parts[-1] if parts else 'root'}",
"description": f"{entry['status']} {entry['verb']} {path}",
"protected": True,
"implemented": entry["status"] in {"implemented", "stub"},
}
)
categories = [
{
"tag": tag,
"paths": sorted(by_path.values(), key=lambda item: item["path"]),
}
for tag, by_path in sorted(grouped.items())
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
"path_count": sum(len(cat["paths"]) for cat in categories),
"method_count": len(entries),
"categories": categories,
"plane": "vsphere-rest",
"contract_kind": bundle.get("kind", "stub-openapi-matrix"),
}
def _field(
name: str,
*,
type_name: str = "string",
optional: bool = False,
example: Any = None,
description: str | None = None,
enum: list[str] | None = None,
) -> dict[str, Any]:
return {
"name": name,
"type": type_name,
"description": description,
"optional": optional,
"enum": enum or [],
"example": example if example is not None else name,
}
def _path_fields(path: str) -> list[dict[str, Any]]:
fields = []
for name in _PATH_PARAM.findall(path):
fields.append(
_field(
name,
optional=False,
example=_PATH_EXAMPLES.get(name, name),
description=f"Path parameter {{{name}}}",
)
)
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"):
continue
example = field.get("example")
if isinstance(example, str) and example.startswith("{"):
try:
import json
body[field["name"]] = json.loads(example)
continue
except Exception:
body[field["name"]] = example
continue
body[field["name"]] = example
return body
def vsphere_method_payload(
*,
major: int,
path: str,
verb: str,
runtime_version: str | None,
) -> dict[str, Any]:
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",
)
)
resolved = path
for field in path_fields:
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
return {
"major": major,
"path": path,
"verb": upper,
"name": path.strip("/").replace("/", "_"),
"description": f"{upper} {path}",
"resolved_path": resolved,
"path_fields": path_fields,
"body_fields": body_fields,
"indexed_fields": [],
"body_example": _body_example_from_fields(body_fields),
"implemented": is_implemented_for_major(upper, path, major),
"runtime_version": runtime_version or meta["version"],
"source_version": meta["version"],
}
+148
View File
@@ -0,0 +1,148 @@
"""Compatibility / Implementation-coverage payload for the lab UI."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import catalog_entries
_EVIDENCE_ROOT = Path(__file__).resolve().parents[3] / "evidence"
def _level(count: int, total: int) -> dict[str, Any]:
total = max(total, 1)
return {"count": count, "score": round(count / total, 4)}
def vsphere_compatibility_payload(
major: int,
*,
runtime_version: str | None = None,
) -> dict[str, Any]:
"""Shape expected by lab UI ``updateCatalogCoverage`` / compatibility help panel."""
meta = VERSIONS.get(major) or VERSIONS[9]
universe = catalog_entries()
active = catalog_entries_for_major(major)
declared = len(universe)
implemented = len(active)
unsupported = max(declared - implemented, 0)
by_verb = Counter(e["verb"] for e in active)
universe_by_verb = Counter(e["verb"] for e in universe)
# Surface matrix treats every registered route as exercised for the active floor.
observed = implemented
verified = implemented
dimensions = {
"route_method": _level(implemented, declared),
"get": _level(by_verb.get("GET", 0), max(universe_by_verb.get("GET", 0), 1)),
"post": _level(by_verb.get("POST", 0), max(universe_by_verb.get("POST", 0), 1)),
"patch": _level(by_verb.get("PATCH", 0), max(universe_by_verb.get("PATCH", 0), 1)),
"delete": _level(by_verb.get("DELETE", 0), max(universe_by_verb.get("DELETE", 0), 1)),
"auth_session": _level(
sum(1 for e in active if e["path"] in {"/api/session", "/rest/com/vmware/cis/session"}),
6,
),
"inventory": _level(
sum(1 for e in active if "/api/vcenter/" in e["path"]),
max(sum(1 for e in universe if "/api/vcenter/" in e["path"]), 1),
),
"legacy_rest": _level(
sum(1 for e in active if e["path"].startswith("/rest/")),
max(sum(1 for e in universe if e["path"].startswith("/rest/")), 1),
),
}
evidence_path = _EVIDENCE_ROOT / f"vsphere-{meta['version']}.json"
evidence_summary: dict[str, Any] = {}
if evidence_path.is_file():
try:
import json
evidence_summary = (
json.loads(evidence_path.read_text(encoding="utf-8")).get("summary") or {}
)
except Exception:
evidence_summary = {}
active_keys = {(a["verb"], a["path"]) for a in active}
gated_entries = [
f"{e['verb']} {e['path']}" for e in universe if (e["verb"], e["path"]) not in active_keys
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"catalog_version": meta["version"],
"runtime_version": runtime_version or meta["version"],
"plane": "vsphere-rest",
"evidence_scope": "vsphere-registry",
"total_declared": declared,
"levels": {
"declared": _level(declared, declared),
# Prefer "gated" in the help UI; keep schema_only as an alias for older clients.
"gated": _level(unsupported, declared),
"schema_only": _level(unsupported, declared),
"implemented": _level(implemented, declared),
"observed": _level(observed, declared),
"verified": _level(verified, declared),
},
"dimensions": dimensions,
"classifications": {
"available": [f"{e['verb']} {e['path']}" for e in active],
"fully_compatible": [f"{e['verb']} {e['path']}" for e in active],
"partially_compatible": [],
"incompatible": [],
"regressions": [],
"unsupported": gated_entries,
"gated_501": gated_entries,
},
"summary": {
"methods": declared,
"implemented": implemented,
"unsupported_in_version": unsupported,
"coverage": round(implemented / max(declared, 1), 4),
"by_verb": dict(sorted(by_verb.items())),
"universe_by_verb": dict(sorted(universe_by_verb.items())),
**{k: v for k, v in evidence_summary.items() if k.startswith("probed")},
},
"entries": active,
}
def evidence_ledger(major: int) -> dict[str, Any]:
"""Compact on-disk ledger written by ``scripts/write_vsphere_evidence.py``."""
from datetime import UTC, datetime
payload = vsphere_compatibility_payload(major)
meta = VERSIONS[major]
return {
"product": "vmware-api-simulator",
"api_version": meta["version"],
"major": major,
"series": meta["series"],
"plane": "vsphere-rest",
"generated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"notes": (
"Ledger derived from app/vsphere/rest/coverage.py + PATH_FLOOR. "
"implemented_methods = available at this major; "
"universe_methods = full simulator registry."
),
"summary": {
"implemented_methods": payload["summary"]["implemented"],
"universe_methods": payload["summary"]["methods"],
"unsupported_in_version": payload["summary"]["unsupported_in_version"],
"coverage": payload["summary"]["coverage"],
"by_verb": payload["summary"]["by_verb"],
"status": "partial-clone"
if payload["summary"]["coverage"] < 1
else "registry-complete",
},
"levels": payload["levels"],
"dimensions": payload["dimensions"],
}
+244
View File
@@ -0,0 +1,244 @@
"""Per-major vSphere REST availability matrix (stub OpenAPI stand-in)."""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.vsphere.rest.coverage import ACTIVE_STATUSES, IMPLEMENTED
# Integer majors mirror the console hot-swap ids (6-9).
VERSIONS: dict[int, dict[str, str]] = {
6: {"series": "vSphere 7.0", "version": "7.0.0"},
7: {"series": "vSphere 7.0 U3", "version": "7.0.3"},
8: {"series": "vSphere 8.0", "version": "8.0.0"},
9: {"series": "vSphere 8.0 U2", "version": "8.0.2"},
}
# Minimum major at which a registered path is considered implemented.
# Anything absent defaults to major 9 (latest) so new paths stay opt-in until catalogued.
_DEFAULT_FLOOR = 9
PATH_FLOOR: dict[tuple[str, str], int] = {
# Always-on core (7.0+)
("POST", "/api/session"): 6,
("DELETE", "/api/session"): 6,
("GET", "/api/session"): 6,
("POST", "/rest/com/vmware/cis/session"): 6,
("GET", "/rest/com/vmware/cis/session"): 6,
("DELETE", "/rest/com/vmware/cis/session"): 6,
("GET", "/api/appliance/system/version"): 6,
("GET", "/api/vcenter/vm"): 6,
("POST", "/api/vcenter/vm"): 6,
("GET", "/api/vcenter/vm/{vm}"): 6,
("DELETE", "/api/vcenter/vm/{vm}"): 6,
("POST", "/api/vcenter/vm/{vm}/power"): 6,
("GET", "/api/vcenter/vm/{vm}/guest/identity"): 6,
("GET", "/api/vcenter/host"): 6,
("GET", "/api/vcenter/host/{host}"): 6,
("GET", "/api/vcenter/datastore"): 6,
("GET", "/api/vcenter/datastore/{datastore}"): 6,
("GET", "/api/vcenter/network"): 6,
("GET", "/api/vcenter/datacenter"): 6,
("GET", "/api/vcenter/cluster"): 6,
("GET", "/api/vcenter/folder"): 6,
("GET", "/api/vcenter/resource-pool"): 6,
# 7.0 U3 depth
("GET", "/api/cis/tasks"): 7,
("GET", "/api/cis/tasks/{task}"): 7,
("GET", "/api/vcenter/vm/{vm}/tools"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
("PATCH", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/memory"): 7,
("PATCH", "/api/vcenter/vm/{vm}/hardware/memory"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/disk"): 7,
("POST", "/api/vcenter/vm/{vm}/hardware/disk"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/ethernet"): 7,
("POST", "/api/vcenter/vm/{vm}/hardware/ethernet"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/boot"): 7,
("GET", "/api/vcenter/vm/{vm}/snapshots"): 7,
("POST", "/api/vcenter/vm/{vm}/snapshots"): 7,
("DELETE", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): 7,
("POST", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): 7,
("POST", "/api/vcenter/vm/{vm}/clone"): 7,
("POST", "/api/vcenter/vm/{vm}/relocate"): 7,
("POST", "/api/vcenter/host/{host}/maintenance"): 7,
("GET", "/api/vcenter/datastore/{datastore}/files"): 7,
("POST", "/api/vcenter/datastore/{datastore}/files"): 7,
("POST", "/api/vcenter/datacenter"): 7,
("DELETE", "/api/vcenter/datacenter/{datacenter}"): 7,
("POST", "/api/vcenter/cluster"): 7,
("DELETE", "/api/vcenter/cluster/{cluster}"): 7,
("POST", "/api/vcenter/folder"): 7,
("POST", "/api/vcenter/folder/{folder}"): 7,
("DELETE", "/api/vcenter/folder/{folder}"): 7,
("POST", "/api/vcenter/resource-pool"): 7,
("DELETE", "/api/vcenter/resource-pool/{resource_pool}"): 7,
("GET", "/api/cis/tagging/category"): 7,
("POST", "/api/cis/tagging/category"): 7,
("GET", "/api/cis/tagging/category/{category_id}"): 7,
("DELETE", "/api/cis/tagging/category/{category_id}"): 7,
("GET", "/api/cis/tagging/tag"): 7,
("POST", "/api/cis/tagging/tag"): 7,
("GET", "/api/cis/tagging/tag/{tag_id}"): 7,
("DELETE", "/api/cis/tagging/tag/{tag_id}"): 7,
("POST", "/api/cis/tagging/tag-association"): 7,
# 8.0 platform services
("GET", "/api/appliance/health/system"): 8,
("GET", "/api/appliance/networking"): 8,
("GET", "/api/appliance/timesync"): 8,
("GET", "/api/vcenter/network/dvs"): 8,
("POST", "/api/vcenter/network/dvs"): 8,
("POST", "/api/vcenter/network/dvpg"): 8,
("GET", "/api/content/library"): 8,
("POST", "/api/content/local-library"): 8,
("GET", "/api/content/library/item"): 8,
("POST", "/api/content/library/item"): 8,
("POST", "/api/vcenter/ovf/library-item/{item_id}"): 8,
("GET", "/api/vcenter/storage/policies"): 8,
("GET", "/api/vcenter/storage/policies/{policy}/vm"): 8,
("GET", "/api/vcenter/privilege"): 8,
("GET", "/api/vcenter/authorization/roles"): 8,
("GET", "/api/vcenter/authorization/permissions"): 8,
("POST", "/api/vcenter/authorization/permissions"): 8,
("DELETE", "/api/vcenter/authorization/permissions/{permission_id}"): 8,
("GET", "/api/vcenter/identity/providers"): 8,
("GET", "/api/vcenter/certificate-management/vcenter/tls"): 9,
("GET", "/api/vcenter/vm/{vm}/guest/networking"): 7,
("GET", "/api/vcenter/vm/{vm}/guest/power"): 7,
("POST", "/api/vcenter/vm/{vm}/guest/power"): 7,
("POST", "/api/vcenter/vm/{vm}/tools"): 7,
("POST", "/api/vcenter/vm/{vm}/console/tickets"): 8,
("POST", "/api/vcenter/vm/{vm}/guest/customization"): 8,
("POST", "/api/vcenter/vm/{vm}"): 7,
("GET", "/api/vcenter/host/{host}/storage/storage-device"): 8,
("GET", "/api/vcenter/host/{host}/networking"): 8,
("GET", "/api/vcenter/folder/{folder}/children"): 7,
("GET", "/api/vapi/metadata/metamodel/service"): 8,
("GET", "/api/vapi/metadata/authentication/component"): 8,
("GET", "/api/vcenter/activity-history"): 8,
("GET", "/rest/vcenter/vm"): 6,
("GET", "/rest/vcenter/vm/{vm}"): 6,
("POST", "/rest/vcenter/vm/{vm}/power"): 6,
("GET", "/rest/vcenter/host"): 6,
("GET", "/rest/vcenter/datastore"): 6,
("GET", "/rest/vcenter/network"): 6,
("GET", "/rest/vcenter/datacenter"): 6,
("GET", "/rest/vcenter/cluster"): 6,
("GET", "/rest/appliance/system/version"): 6,
}
def floor_for(verb: str, path: str) -> int:
return PATH_FLOOR.get((verb.upper(), path), _DEFAULT_FLOOR)
def methods_for_major(major: int) -> dict[tuple[str, str], str]:
active = major if major in VERSIONS else 9
return {
key: status for key, status in IMPLEMENTED.items() if floor_for(key[0], key[1]) <= active
}
def catalog_entries_for_major(major: int) -> list[dict[str, str]]:
return [
{"verb": verb, "path": path, "status": status}
for (verb, path), status in sorted(methods_for_major(major).items())
]
def is_implemented_for_major(verb: str, path: str, major: int) -> bool:
return methods_for_major(major).get((verb.upper(), path)) in ACTIVE_STATUSES
@lru_cache(maxsize=1)
def _compiled_routes() -> list[tuple[str, re.Pattern[str], str, int, int]]:
compiled: list[tuple[str, re.Pattern[str], str, int, int]] = []
for verb, path in IMPLEMENTED:
pattern = "^" + re.sub(r"\{[^/]+\}", r"[^/]+", path) + "$"
parts = [part for part in path.split("/") if part]
static = sum(1 for part in parts if not (part.startswith("{") and part.endswith("}")))
compiled.append((verb, re.compile(pattern), path, static, len(path)))
# Prefer more literal segments so /library/item beats /library/{library_id}.
compiled.sort(key=lambda item: (item[0], -item[3], -item[4], item[2]))
return compiled
def resolve_template(verb: str, request_path: str) -> str | None:
method = verb.upper()
for route_verb, pattern, template, _static, _length in _compiled_routes():
if route_verb == method and pattern.match(request_path):
return template
return None
def available_for_request(verb: str, request_path: str, major: int) -> bool | None:
"""True = allowed, False = known but wrong version, None = not in registry.
Lab policy: every registered Automation API method is always served with real
(DB-backed) handlers/stubs regardless of the hot-swapped catalog major.
Catalog browse still uses ``methods_for_major`` / PATH_FLOOR for history.
"""
del major # major retained for call-site compatibility; gating is catalog-only
template = resolve_template(verb, request_path)
if template is None:
return None
return True
def bundle_payload(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
entries = catalog_entries_for_major(major)
return {
"product": "vmware-api-simulator",
"plane": "vsphere-rest",
"major": major,
"series": meta["series"],
"version": meta["version"],
"kind": "stub-openapi-matrix",
"notes": (
"Stub contract derived from app/vsphere/rest/coverage.py + PATH_FLOOR. "
"Not a Broadcom OpenAPI dump; used for catalog browse and hot-swap gating."
),
"method_count": len(entries),
"methods": entries,
}
def bundles_root() -> Path:
return Path(__file__).resolve().parents[3] / "contracts" / "vsphere"
def write_bundles(root: Path | None = None) -> list[Path]:
base = root or bundles_root()
written: list[Path] = []
for major in sorted(VERSIONS):
meta = VERSIONS[major]
directory = base / meta["version"]
directory.mkdir(parents=True, exist_ok=True)
payload = bundle_payload(major)
path = directory / "manifest.json"
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
written.append(path)
(base / "README.md").write_text(
"# vSphere stub contracts\n\n"
"Versioned JSON matrices generated from `app/vsphere/contracts/matrix.py`.\n"
"Hot-swap (`POST /ui/api/contract/apply?major=N`) switches the catalog major "
"for UI browse/evidence. Runtime always serves the full registered surface "
"(no HTTP 501 version gate on known paths).\n",
encoding="utf-8",
)
return written
def load_bundle(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
path = bundles_root() / meta["version"] / "manifest.json"
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
return bundle_payload(major)