Initial release of the oVirt/RHV Engine API simulator.

Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release
targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
2026-07-18 04:49:28 +03:00
commit cbd0adca91
218 changed files with 246804 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""HTTP adapters."""
+51
View File
@@ -0,0 +1,51 @@
"""Base external error representation."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
class ApiError(Exception):
"""A safe error intended for the Engine API boundary."""
def __init__(
self, status_code: int, message: str, errors: dict[str, str] | None = None
) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.errors = errors
class ContractValidationError(ApiError):
def __init__(self, errors: dict[str, str]) -> None:
super().__init__(400, "parameter verification failed", errors)
async def api_error_handler(_request: Request, exc: Exception) -> JSONResponse:
if not isinstance(exc, ApiError):
raise TypeError("api_error_handler received an incompatible exception")
body: dict[str, Any] = {"data": None, "message": exc.message}
if exc.errors is not None:
body["errors"] = exc.errors
return JSONResponse(status_code=exc.status_code, content=body)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Log internal failures and return a stable non-FastAPI error envelope."""
logger.exception(
"unhandled request error",
extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path},
)
body: dict[str, Any] = {
"data": None,
"errors": {"internal": "internal server error"},
}
return JSONResponse(status_code=500, content=body)
+42
View File
@@ -0,0 +1,42 @@
"""Request correlation and access logging middleware."""
from __future__ import annotations
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
RequestHandler = Callable[[Request], Awaitable[Response]]
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Attach a bounded request ID and log one structured completion event."""
def __init__(self, app: object, header_name: str) -> None:
super().__init__(app) # type: ignore[arg-type]
self._header_name = header_name
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
supplied = request.headers.get(self._header_name, "")
request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4())
request.state.request_id = request_id
started = time.monotonic()
response = await call_next(request)
response.headers[self._header_name] = request_id
logger.info(
"request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round((time.monotonic() - started) * 1000, 3),
},
)
return response
+94
View File
@@ -0,0 +1,94 @@
"""OpenAPI tag resolution for Engine API and simulator routes."""
from __future__ import annotations
_COLLECTION_LABELS: dict[str, str] = {
"vms": "VMs",
"disks": "Disks",
"hosts": "Hosts",
"clusters": "Clusters",
"datacenters": "Data Centers",
"networks": "Networks",
"vnicprofiles": "vNIC Profiles",
"storagedomains": "Storage Domains",
"storageconnections": "Storage Connections",
"templates": "Templates",
"users": "Users",
"groups": "Groups",
"roles": "Roles",
"permissions": "Permissions",
"domains": "Domains",
"events": "Events",
"jobs": "Jobs",
"tags": "Tags",
"bookmarks": "Bookmarks",
"affinitylabels": "Affinity Labels",
"instancetypes": "Instance Types",
"macpools": "MAC Pools",
"schedulingpolicies": "Scheduling Policies",
"schedulingpolicyunits": "Scheduling Policy Units",
"clusterlevels": "Cluster Levels",
"icons": "Icons",
"operatingsystems": "Operating Systems",
"networkfilters": "Network Filters",
"vmpools": "VM Pools",
"katelloerrata": "Katello Errata",
"externalhostproviders": "External Host Providers",
"openstacknetworkproviders": "OpenStack Network Providers",
"openstackimageproviders": "OpenStack Image Providers",
"openstackvolumeproviders": "OpenStack Volume Providers",
"imagetransfers": "Image Transfers",
"options": "Options",
}
def contract_openapi_tag(path: str) -> str:
"""Map an Engine API path to a Swagger UI category."""
parts = [part for part in path.strip("/").split("/") if part]
if parts[:2] == ["ovirt-engine", "api"]:
parts = parts[2:]
if parts and parts[0] in {"v3", "v4"}:
parts = parts[1:]
if not parts:
return "engine"
root = parts[0]
return _COLLECTION_LABELS.get(root, root.replace("-", " ").title())
def contract_openapi_tags(path: str, renderer: str | None = None) -> list[str]:
"""Return OpenAPI tags for a contract route.
``renderer`` is accepted for call-site compatibility; Engine API has a
single representation surface.
"""
del renderer
return [contract_openapi_tag(path)]
def openapi_tag_metadata() -> list[dict[str, str]]:
"""Descriptions shown in Swagger UI for each tag group."""
tags: list[dict[str, str]] = [
{
"name": "engine",
"description": "oVirt Engine REST API root under `/ovirt-engine/api`.",
},
{
"name": "sso",
"description": "Engine SSO OAuth2 token endpoints.",
},
{
"name": "Simulator",
"description": "Health checks, compatibility reports, and the web console.",
},
]
for label in sorted(set(_COLLECTION_LABELS.values())):
tags.append(
{
"name": label,
"description": f"Engine {label} collection under `/ovirt-engine/api`.",
}
)
return tags