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,349 @@
|
||||
"""DB-backed per-path routes for Broadcom Automation API stubs without deep handlers.
|
||||
|
||||
Each ``(verb, path)`` from the universe registry is registered with
|
||||
``APIRouter.add_api_route`` (same idea as Proxmox ``register_contract_routes``),
|
||||
instead of a single ``/api/{full_path:path}`` catch-all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db.pool import Database
|
||||
from app.dependencies import get_database
|
||||
from app.vsphere import inventory
|
||||
from app.vsphere.domain import api_state, tagging
|
||||
from app.vsphere.domain import content as content_domain
|
||||
from app.vsphere.errors import not_found
|
||||
from app.vsphere.rest.coverage import IMPLEMENTED
|
||||
from app.vsphere.security.authz import require_read
|
||||
from app.vsphere.security.session import SessionInfo
|
||||
|
||||
router = APIRouter(tags=["vSphere REST surface"])
|
||||
|
||||
_PARAM_RE = re.compile(r"\{([A-Za-z0-9_]+)\}")
|
||||
|
||||
|
||||
def _extract_params(template: str, concrete: str) -> dict[str, str]:
|
||||
pattern = "^" + _PARAM_RE.sub(r"([^/]+)", template) + "$"
|
||||
match = re.match(pattern, concrete)
|
||||
if not match:
|
||||
return {}
|
||||
names = _PARAM_RE.findall(template)
|
||||
return {name: match.group(index + 1) for index, name in enumerate(names)}
|
||||
|
||||
|
||||
async def _live_get(database: Database, template: str, concrete: str) -> Any | None:
|
||||
"""Return inventory/platform-backed payloads when possible."""
|
||||
|
||||
params = _extract_params(template, concrete)
|
||||
|
||||
if template == "/api/cis/tagging/category":
|
||||
return await tagging.list_categories(database)
|
||||
if template == "/api/cis/tagging/tag":
|
||||
return await tagging.list_tags(database)
|
||||
if template == "/api/content/library":
|
||||
return await content_domain.list_libraries(database)
|
||||
if template == "/api/content/library/item":
|
||||
libs = await content_domain.list_libraries(database)
|
||||
items: list[dict[str, Any]] = []
|
||||
for lib in libs:
|
||||
items.extend(await content_domain.list_library_items(database, lib["id"]))
|
||||
return items
|
||||
if template == "/api/content/local-library":
|
||||
libs = await content_domain.list_libraries(database)
|
||||
return [lib for lib in libs if lib.get("type") == "LOCAL"]
|
||||
|
||||
vm = params.get("vm")
|
||||
if vm and "/hardware/" in template:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
props = obj.props or {}
|
||||
|
||||
def _live_list(key: str) -> list[Any] | None:
|
||||
value = props.get(key)
|
||||
if isinstance(value, list) and value:
|
||||
return list(value)
|
||||
return None
|
||||
|
||||
# Prefer non-empty inventory props; otherwise fall through to vsphere_api_state.
|
||||
if template.endswith("/hardware/cdrom"):
|
||||
return _live_list("cdroms")
|
||||
if template.endswith("/hardware/floppy"):
|
||||
return _live_list("floppies")
|
||||
if template.endswith("/hardware/serial"):
|
||||
return _live_list("serials")
|
||||
if template.endswith("/hardware/parallel"):
|
||||
return _live_list("parallels")
|
||||
if template.endswith("/hardware/adapter/scsi"):
|
||||
return _live_list("scsi_adapters")
|
||||
if template.endswith("/hardware/adapter/sata"):
|
||||
return _live_list("sata_adapters")
|
||||
if template.endswith("/hardware/adapter/nvme"):
|
||||
return _live_list("nvme_adapters")
|
||||
if template.endswith("/hardware/boot") and isinstance(props.get("boot"), dict):
|
||||
return props["boot"]
|
||||
if template.endswith("/hardware/boot/device"):
|
||||
return _live_list("boot_devices")
|
||||
if template.endswith("/hardware/disk"):
|
||||
return _live_list("disks") or list(props.get("disks") or [])
|
||||
if template.endswith("/hardware/ethernet"):
|
||||
return _live_list("nics") or list(props.get("nics") or [])
|
||||
if "/hardware/disk/" in template and template.endswith("}"):
|
||||
disk_id = params.get("disk")
|
||||
for disk in props.get("disks") or []:
|
||||
if str(disk.get("key") or disk.get("disk")) == str(disk_id):
|
||||
return disk
|
||||
if "/hardware/ethernet/" in template and template.endswith("}"):
|
||||
nic_id = params.get("nic")
|
||||
for nic in props.get("nics") or []:
|
||||
if str(nic.get("key") or nic.get("nic")) == str(nic_id):
|
||||
return nic
|
||||
|
||||
if vm and "/guest/" in template:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
props = obj.props or {}
|
||||
if template.endswith("/guest/local-filesystem"):
|
||||
if "guest_filesystems" in props:
|
||||
return props["guest_filesystems"]
|
||||
return None
|
||||
identity = props.get("identity") or {}
|
||||
if identity or props.get("guest_OS") or props.get("guest_ip"):
|
||||
return {
|
||||
"name": identity.get("name") or obj.name,
|
||||
"family": "LINUX"
|
||||
if "WIN" not in str(props.get("guest_OS", "")).upper()
|
||||
else "WINDOWS",
|
||||
"full_name": {"name": props.get("guest_OS") or obj.name},
|
||||
"host_name": identity.get("name") or obj.name,
|
||||
"ip_address": props.get("guest_ip"),
|
||||
}
|
||||
return None
|
||||
|
||||
host = params.get("host")
|
||||
if host and template.endswith("/networking"):
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None:
|
||||
raise not_found(f"Host {host} not found")
|
||||
networking = (obj.props or {}).get("networking")
|
||||
return networking if networking is not None else None
|
||||
if host and "storage-device" in template:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None:
|
||||
raise not_found(f"Host {host} not found")
|
||||
devices = (obj.props or {}).get("storage_devices")
|
||||
return devices if devices is not None else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _mutate_vm_hardware(
|
||||
database: Database,
|
||||
template: str,
|
||||
concrete: str,
|
||||
verb: str,
|
||||
body: dict[str, Any],
|
||||
) -> Any | None:
|
||||
params = _extract_params(template, concrete)
|
||||
vm = params.get("vm")
|
||||
if not vm or "/hardware/" not in template:
|
||||
return False
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
props = dict(obj.props or {})
|
||||
|
||||
if verb == "POST" and template.endswith("/hardware/cdrom"):
|
||||
items = list(props.get("cdroms") or [])
|
||||
key = str(3000 + len(items))
|
||||
items.append(
|
||||
{"cdrom": key, "label": f"CD/DVD drive {len(items) + 1}", "state": "CONNECTED", **body}
|
||||
)
|
||||
props["cdroms"] = items
|
||||
await inventory.upsert_object(
|
||||
database,
|
||||
moid=vm,
|
||||
type_name=obj.type,
|
||||
name=obj.name,
|
||||
parent_moid=obj.parent_moid,
|
||||
props=props,
|
||||
)
|
||||
return key
|
||||
if verb in {"PUT", "PATCH"} and template.endswith("/hardware/boot"):
|
||||
props["boot"] = {**(props.get("boot") or {}), **body}
|
||||
await inventory.upsert_object(
|
||||
database,
|
||||
moid=vm,
|
||||
type_name=obj.type,
|
||||
name=obj.name,
|
||||
parent_moid=obj.parent_moid,
|
||||
props=props,
|
||||
)
|
||||
return None
|
||||
if verb == "DELETE" and "/hardware/cdrom/" in template:
|
||||
cdrom = params.get("cdrom")
|
||||
props["cdroms"] = [
|
||||
c for c in (props.get("cdroms") or []) if str(c.get("cdrom")) != str(cdrom)
|
||||
]
|
||||
await inventory.upsert_object(
|
||||
database,
|
||||
moid=vm,
|
||||
type_name=obj.type,
|
||||
name=obj.name,
|
||||
parent_moid=obj.parent_moid,
|
||||
props=props,
|
||||
)
|
||||
return None
|
||||
return False # not handled specialized; fall through to api_state
|
||||
|
||||
|
||||
async def _dispatch(
|
||||
template: str,
|
||||
verb: str,
|
||||
request: Request,
|
||||
database: Database,
|
||||
) -> Response:
|
||||
concrete = request.url.path
|
||||
method = verb.upper()
|
||||
raw_body: Any = {}
|
||||
if method in {"POST", "PUT", "PATCH"}:
|
||||
try:
|
||||
raw_body = await request.json()
|
||||
except Exception:
|
||||
raw_body = {}
|
||||
if not isinstance(raw_body, dict):
|
||||
raw_body = {"value": raw_body}
|
||||
|
||||
if method == "GET":
|
||||
live = await _live_get(database, template, concrete)
|
||||
if live is not None and not api_state.is_empty_payload(live):
|
||||
return JSONResponse(content=live, status_code=200)
|
||||
stored = await api_state.get_payload_or_seed(database, "GET", template)
|
||||
if stored is None or api_state.is_empty_payload(stored):
|
||||
return JSONResponse(content={"path": template, "status": "NOT_SEEDED"}, status_code=404)
|
||||
return JSONResponse(content=stored, status_code=200)
|
||||
|
||||
hw = await _mutate_vm_hardware(database, template, concrete, method, raw_body)
|
||||
if hw is not False:
|
||||
if isinstance(hw, str):
|
||||
return JSONResponse(content=hw, status_code=201)
|
||||
return Response(status_code=204)
|
||||
|
||||
if method in {"PUT", "PATCH"}:
|
||||
existing = await api_state.get_payload(database, "GET", template)
|
||||
if isinstance(existing, dict) and isinstance(raw_body, dict):
|
||||
merged = {**existing, **raw_body}
|
||||
elif isinstance(raw_body, dict) and raw_body:
|
||||
merged = raw_body
|
||||
elif existing is not None:
|
||||
merged = existing
|
||||
else:
|
||||
merged = {}
|
||||
await api_state.put_payload(database, "GET", template, merged)
|
||||
return JSONResponse(content=merged, status_code=200)
|
||||
|
||||
if method == "DELETE":
|
||||
# Soft-delete: restore seed_payload from DB so lab GETs never go empty/404.
|
||||
await api_state.restore_seed_payload(database, "GET", template)
|
||||
if template.endswith("}"):
|
||||
parent = template.rsplit("/", 1)[0]
|
||||
if parent:
|
||||
await api_state.restore_seed_payload(database, "GET", parent)
|
||||
return Response(status_code=204)
|
||||
|
||||
# POST create / action
|
||||
action = request.query_params.get("action")
|
||||
if action:
|
||||
await api_state.put_payload(
|
||||
database,
|
||||
"GET",
|
||||
template,
|
||||
{
|
||||
"last_action": action,
|
||||
"accepted": True,
|
||||
"path": template,
|
||||
**({} if not raw_body else {"spec": raw_body}),
|
||||
},
|
||||
)
|
||||
if action.endswith("Task") or "task" in action.lower():
|
||||
from app.vsphere.domain import tasks as task_store
|
||||
|
||||
task_id = await task_store.create_task(
|
||||
database,
|
||||
description=f"{action} {template}",
|
||||
service="com.vmware.vapi",
|
||||
operation=action,
|
||||
status="SUCCEEDED",
|
||||
result={"path": template, "action": action},
|
||||
)
|
||||
return JSONResponse(content=task_id, status_code=200)
|
||||
return JSONResponse(content={"status": "SUCCESS", "action": action}, status_code=200)
|
||||
|
||||
new_id = await api_state.new_id(template.rstrip("/").rsplit("/", 1)[-1].strip("{}") or "id")
|
||||
collection_payload = await api_state.get_payload(database, "GET", template)
|
||||
created = {"id": new_id, "name": raw_body.get("name") or new_id, **raw_body}
|
||||
if isinstance(collection_payload, list):
|
||||
collection_payload = [*collection_payload, created]
|
||||
await api_state.put_payload(database, "GET", template, collection_payload)
|
||||
else:
|
||||
await api_state.put_payload(
|
||||
database, "GET", f"{template}/{{{template.rsplit('/', 1)[-1]}}}", created
|
||||
)
|
||||
await api_state.put_payload(database, "GET", template, created)
|
||||
return JSONResponse(content=new_id, status_code=201)
|
||||
|
||||
|
||||
def _endpoint(
|
||||
template: str,
|
||||
verb: str,
|
||||
) -> Callable[..., Awaitable[Response]]:
|
||||
async def dispatch(
|
||||
request: Request,
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> Response:
|
||||
return await _dispatch(template, verb, request, database)
|
||||
|
||||
dispatch.__name__ = f"vsphere_stub_{verb}_{template.replace('/', '_').strip('_')}"
|
||||
dispatch.__qualname__ = dispatch.__name__
|
||||
return dispatch
|
||||
|
||||
|
||||
def register_stub_routes(
|
||||
target: APIRouter | None = None,
|
||||
*,
|
||||
implemented: dict[tuple[str, str], str] | None = None,
|
||||
) -> int:
|
||||
"""Register one FastAPI route per stub ``(verb, path)`` from the coverage registry.
|
||||
|
||||
Deep ``CORE_IMPLEMENTED`` handlers are skipped so they keep winning on their
|
||||
dedicated routers. Returns the number of routes added.
|
||||
"""
|
||||
|
||||
api = target if target is not None else router
|
||||
registry = implemented if implemented is not None else IMPLEMENTED
|
||||
added = 0
|
||||
for (verb, path), status in sorted(registry.items(), key=lambda item: (item[0][1], item[0][0])):
|
||||
if status != "stub":
|
||||
continue
|
||||
api.add_api_route(
|
||||
path,
|
||||
_endpoint(path, verb),
|
||||
methods=[verb],
|
||||
name=f"vsphere-stub:{verb}:{path}",
|
||||
include_in_schema=True,
|
||||
openapi_extra={"x-vmware-implementation": "stub"},
|
||||
)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
register_stub_routes()
|
||||
Reference in New Issue
Block a user