Initial commit: stateful OpenStack API laboratory simulator.

Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 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 Proxmox-compatible 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
+182
View File
@@ -0,0 +1,182 @@
"""OpenAPI tag metadata for the OpenStack simulator.
Legacy Proxmox path→tag helpers remain for optional ``CONTRACT_SNAPSHOT`` mode;
they are not pre-declared in Swagger (see ``openapi_tag_metadata``).
"""
from __future__ import annotations
_NODE_SECTION_LABELS: dict[str, str] = {
"qemu": "QEMU",
"lxc": "LXC",
"ceph": "Ceph",
"storage": "Storage",
"sdn": "SDN",
"firewall": "Firewall",
"apt": "APT",
"certificates": "Certificates",
"scan": "Scan",
"network": "Network",
"services": "Services",
"capabilities": "Capabilities",
"hardware": "Hardware",
"replication": "Replication",
"tasks": "Tasks",
"subscription": "Subscription",
"vzdump": "Backup",
"disks": "Disks",
"config": "Config",
"dns": "DNS",
"hosts": "Hosts",
"status": "Status",
"time": "Time",
"aplinfo": "Appliance",
}
_CLUSTER_SECTION_LABELS: dict[str, str] = {
"sdn": "SDN",
"firewall": "Firewall",
"notifications": "Notifications",
"ha": "HA",
"mapping": "Mapping",
"acme": "ACME",
"config": "Config",
"ceph": "Ceph",
"jobs": "Jobs",
"metrics": "Metrics",
"qemu": "QEMU",
"backup": "Backup",
"bulk-action": "Bulk Action",
"replication": "Replication",
"backup-info": "Backup Info",
"options": "Options",
"log": "Log",
"nextid": "Next ID",
"resources": "Resources",
"status": "Status",
"tasks": "Tasks",
}
# Friendly Swagger tag names for OpenStack service packs.
_SERVICE_TAG_LABELS: dict[str, str] = {
"keystone": "Keystone",
"nova": "Nova",
"neutron": "Neutron",
"glance": "Glance",
"cinder": "Cinder",
"placement": "Placement",
"heat": "Heat",
"heat-cfn": "Heat CFN",
"swift": "Swift",
"ironic": "Ironic",
"octavia": "Octavia",
"barbican": "Barbican",
"manila": "Manila",
"designate": "Designate",
"magnum": "Magnum",
"zun": "Zun",
"trove": "Trove",
"mistral": "Mistral",
"aodh": "Aodh",
"freezer": "Freezer",
"blazar": "Blazar",
"vitrage": "Vitrage",
"masakari": "Masakari",
"tacker": "Tacker",
"adjutant": "Adjutant",
"cloudkitty": "CloudKitty",
"watcher": "Watcher",
"zaqar": "Zaqar",
}
_SERVICE_TAG_DESCRIPTIONS: dict[str, str] = {
"OpenStack": "Root discovery and service catalog helpers.",
"Keystone": "Identity API v3 — auth, projects, users, roles, and domains.",
"Nova": "Compute API — servers, flavors, keypairs, and related actions.",
"Neutron": "Networking API — networks, subnets, ports, routers, and security groups.",
"Glance": "Image API — images and image members.",
"Cinder": "Block Storage API — volumes, snapshots, and types.",
"Placement": "Placement API — resource providers and inventories.",
"Heat": "Orchestration API — stacks and resources.",
"Heat CFN": "CloudFormation-compatible Heat API.",
"Swift": "Object Storage API — accounts, containers, and objects.",
"Ironic": "Bare Metal API — nodes and ports.",
"Octavia": "Load Balancer API — load balancers, listeners, and pools.",
"Barbican": "Key Manager API — secrets and containers.",
"Manila": "Shared File Systems API.",
"Designate": "DNS-as-a-Service API.",
"Magnum": "Container Infrastructure Management API.",
"Zun": "Containers API.",
"Trove": "Database-as-a-Service API.",
"Mistral": "Workflow API.",
"Aodh": "Alarming API.",
"Freezer": "Backup API.",
"Blazar": "Reservation API.",
"Vitrage": "Root Cause Analysis API.",
"Masakari": "Instance High Availability API.",
"Tacker": "NFV Orchestration API.",
"Adjutant": "Admin Automation API.",
"CloudKitty": "Rating API.",
"Watcher": "Infrastructure Optimization API.",
"Zaqar": "Messaging API.",
"Simulator": "Health checks, catalog UI, and simulator administration.",
}
def service_openapi_tag(service: str) -> str:
"""Swagger tag for an OpenStack service pack (matches specialized router tags)."""
key = (service or "").strip().lower()
if key in _SERVICE_TAG_LABELS:
return _SERVICE_TAG_LABELS[key]
return key.replace("-", " ").title() or "OpenStack"
def contract_openapi_tag(path: str) -> str:
"""Map a semantic contract path to a category (legacy Proxmox contracts)."""
parts = [part for part in path.strip("/").split("/") if part]
if not parts or parts == ["version"]:
return "Core"
root = parts[0]
if root == "access":
return "Access"
if root == "nodes":
if len(parts) >= 3 and parts[1] == "{node}":
section = parts[2]
label = _NODE_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Nodes · {label}"
return "Nodes"
if root == "cluster":
if len(parts) >= 2:
section = parts[1]
label = _CLUSTER_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Cluster · {label}"
return "Cluster"
if root == "storage":
return "Storage"
if root == "pools":
return "Pools"
return root.replace("-", " ").title()
def contract_openapi_tags(path: str, renderer: str) -> list[str]:
"""Return OpenAPI tags for a legacy Proxmox contract route."""
renderer_label = "API2 JSON" if renderer == "json" else "API2 ExtJS"
return [contract_openapi_tag(path), renderer_label]
def openapi_tag_metadata() -> list[dict[str, str]]:
"""Descriptions shown in Swagger UI for each OpenStack tag group."""
from app.openstack.surface import SERVICES
descriptions = dict(_SERVICE_TAG_DESCRIPTIONS)
for spec in SERVICES:
tag = service_openapi_tag(spec.name)
descriptions.setdefault(
tag,
f"{spec.typ.title()} API ({spec.name}) on port {spec.port}.",
)
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
+375
View File
@@ -0,0 +1,375 @@
"""Contract-driven dynamic route and semantic handler registry."""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import parse_qsl
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.errors import ApiError, ContractValidationError
from app.api.openapi import contract_openapi_tags
from app.config import Settings
from app.contracts.examples import schema_example
from app.contracts.model import Method, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
FallbackMode = Literal["error", "schema-default", "fixture"]
class RouteCollisionError(ValueError):
pass
@dataclass(slots=True)
class HandlerRegistry:
_handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)
def register(self, path: str, verb: str, handler: Handler) -> None:
key = (path, verb.upper())
if key in self._handlers:
raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
self._handlers[key] = handler
def get(self, path: str, verb: str) -> Handler | None:
return self._handlers.get((path, verb.upper()))
def keys(self) -> frozenset[tuple[str, str]]:
return frozenset(self._handlers)
def register_contract_routes(
app: FastAPI,
snapshot: Snapshot,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
*,
existing: set[tuple[str, str, str]] | None = None,
require_handler: bool = False,
allow_existing: bool = False,
) -> set[tuple[str, str, str]]:
"""Register `/api2/{json,extjs}` routes for a contract snapshot.
When ``allow_existing`` is true, path/verb pairs already present in
``existing`` are skipped (used to merge older majors onto a primary contract).
When ``require_handler`` is true, only methods with a registered semantic
handler are added — used for legacy-path aliases.
"""
seen = existing if existing is not None else set()
for contract_path in snapshot.paths:
for contract_method in contract_path.methods:
if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
continue
for renderer in ("json", "extjs"):
route = f"/api2/{renderer}{contract_path.path}"
key = (route, contract_method.verb, renderer)
if key in seen:
if allow_existing:
continue
raise RouteCollisionError(
f"duplicate contract route: {contract_method.verb} {route}"
)
seen.add(key)
implemented = handlers.get(contract_path.path, contract_method.verb) is not None
endpoint = _endpoint(
contract_path.path,
contract_method,
renderer,
handlers,
fallback,
)
app.add_api_route(
route,
endpoint,
methods=[contract_method.verb],
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
tags=cast(
list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
),
openapi_extra={
"x-proxmox-method-checksum": contract_method.checksum,
"x-proxmox-implementation": "implemented" if implemented else "unsupported",
},
)
return seen
def register_legacy_handler_routes(
app: FastAPI,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode = "error",
*,
primary_version: str | None = None,
existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
"""Expose handler-backed paths declared only in older cached contracts."""
seen = existing if existing is not None else set()
if not store_root.is_dir():
return seen
for revision_dir in sorted(store_root.iterdir()):
snapshot_path = revision_dir / "snapshot.json"
if not snapshot_path.is_file():
continue
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
if primary_version and snapshot.source_version == primary_version:
continue
seen = register_contract_routes(
app,
snapshot,
handlers,
fallback,
existing=seen,
require_handler=True,
allow_existing=True,
)
return seen
def _endpoint(
semantic_path: str,
method: Method,
renderer: str,
handlers: HandlerRegistry,
fallback: FallbackMode,
) -> Callable[[Request], Awaitable[JSONResponse]]:
async def dispatch(request: Request) -> JSONResponse:
inputs = await _parse_inputs(request, method)
await _authenticate(request, semantic_path, method, inputs)
handler = handlers.get(semantic_path, method.verb)
if handler is not None:
data = await handler(request, inputs)
elif fallback == "schema-default":
data = schema_example(method.returns)
elif fallback == "fixture" and "fixture" in method.extra:
data = method.extra["fixture"]
else:
return JSONResponse(
status_code=501,
content={"data": None, "errors": "handler pending for this contract method"},
)
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
response = JSONResponse(content)
if semantic_path == "/access/ticket" and isinstance(data, dict):
ticket = data.get("ticket")
if isinstance(ticket, str):
response.set_cookie(
"PVEAuthCookie", ticket, httponly=True, samesite="strict", path="/"
)
return response
return dispatch
async def _authenticate(
request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
) -> None:
if semantic_path in {"/version", "/access/ticket"}:
return
authorization = request.headers.get("Authorization", "")
token_privileges: frozenset[str] | None = None
principal: str
if authorization.startswith("PVEAPIToken="):
database = cast(AsyncpgDatabase, request.app.state.database)
try:
parsed_token = parse_api_token(authorization)
except ValueError as error:
raise ApiError(401, "authentication failure") from error
row = await database.pool.fetchrow(
"""SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 AND t.token_id=$2
AND (t.expires_at IS NULL OR t.expires_at > now())""",
parsed_token.principal,
parsed_token.token_id,
)
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
raise ApiError(401, "authentication failure")
principal = str(row["name"])
token_privileges = (
frozenset(str(item) for item in row["privileges"])
if bool(row["privilege_separation"])
else None
)
else:
ticket = request.cookies.get("PVEAuthCookie")
if ticket is None:
raise ApiError(401, "authentication required")
settings = cast(Settings, request.app.state.settings)
key = settings.ticket_signing_key.get_secret_value().encode()
try:
claims = verify_ticket(ticket, key)
except ValueError as error:
raise ApiError(401, "authentication failure") from error
principal = claims.principal
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_value = request.headers.get("CSRFPreventionToken", "")
if not verify_csrf(ticket, csrf_value, key):
raise ApiError(403, "invalid CSRF prevention token")
request.state.principal = principal
if principal == "root@pam" and token_privileges is None:
return
database = cast(AsyncpgDatabase, request.app.state.database)
await _authorize(database, principal, token_privileges, semantic_path, method, inputs)
async def _authorize(
database: AsyncpgDatabase,
principal: str,
token_privileges: frozenset[str] | None,
semantic_path: str,
method: Method,
inputs: dict[str, Any],
) -> None:
values = cast(dict[str, Any], inputs["values"])
requirement = requirement_from_contract(
method.permissions, {name: str(value) for name, value in values.items()}
)
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None:
return
rows = await database.pool.fetch(
"""SELECT a.path, a.propagate, r.privileges
FROM acl_entries a JOIN roles r ON r.name=a.role_name
JOIN principals p ON p.id=a.principal_id WHERE p.name=$1
UNION ALL
SELECT a.path, a.propagate, r.privileges
FROM group_acl_entries a JOIN roles r ON r.name=a.role_name
JOIN identity_group_members m ON m.group_id=a.group_id
JOIN principals p ON p.id=m.principal_id WHERE p.name=$1""",
principal,
)
entries = tuple(
AclEntry(
principal,
str(row["path"]),
frozenset(str(item) for item in row["privileges"]),
bool(row["propagate"]),
)
for row in rows
)
if not authorize(
principal,
requirement.path,
requirement.privileges,
entries,
token_privileges=token_privileges,
require_all=requirement.require_all,
):
raise ApiError(403, "permission check failed")
async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
supplied: dict[str, Any] = dict(request.query_params)
supplied.update(request.path_params)
if request.method not in {"GET", "DELETE"}:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip()
if content_type == "application/json":
try:
body = await request.json()
except ValueError as exc:
raise ContractValidationError({"body": "invalid JSON"}) from exc
if not isinstance(body, dict):
raise ContractValidationError({"body": "expected an object"})
supplied.update(body)
elif content_type == "application/x-www-form-urlencoded":
supplied.update(dict(parse_qsl((await request.body()).decode())))
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
indexed = {
re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
for name, definition in definitions.items()
if "[n]" in name
}
errors: dict[str, str] = {}
parsed: dict[str, Any] = {}
for name, definition in definitions.items():
if "[n]" in name:
continue
if name not in supplied:
if definition.optional:
if definition.default is not None:
parsed[name] = definition.default
continue
errors[name] = "property is missing and it is not optional"
continue
try:
parsed[name] = _coerce(supplied[name], definition)
except (TypeError, ValueError) as exc:
errors[name] = str(exc)
indexed_names: set[str] = set()
for name in supplied.keys() - definitions.keys():
indexed_definition = next(
(candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
)
if indexed_definition is not None:
indexed_names.add(name)
try:
parsed[name] = _coerce(supplied[name], indexed_definition)
except (TypeError, ValueError) as exc:
errors[name] = str(exc)
for name in supplied.keys() - definitions.keys() - indexed_names:
if name not in request.path_params:
errors[name] = "property is not defined in schema"
if errors:
raise ContractValidationError(dict(sorted(errors.items())))
# Path params are always available to handlers even when omitted from the
# method property schema (common for Proxmox nested resources).
for name, value in request.path_params.items():
parsed.setdefault(name, value)
return {
"values": parsed,
"path": dict(request.path_params),
"provided": tuple(sorted(supplied)),
}
def _coerce(value: Any, schema: Schema) -> Any:
if schema.type == "integer":
parsed: Any = int(value)
elif schema.type == "number":
parsed = float(value)
elif schema.type == "boolean":
if isinstance(value, bool):
parsed = value
elif str(value).lower() in {"1", "true", "yes", "on"}:
parsed = True
elif str(value).lower() in {"0", "false", "no", "off"}:
parsed = False
else:
raise ValueError("expected a boolean")
elif schema.type == "string" or schema.type is None:
parsed = str(value)
else:
parsed = value
if schema.enum and parsed not in schema.enum:
raise ValueError("value is not in the allowed enumeration")
if isinstance(parsed, int | float):
if schema.minimum is not None and parsed < schema.minimum:
raise ValueError(f"value must be at least {schema.minimum}")
if schema.maximum is not None and parsed > schema.maximum:
raise ValueError(f"value must be at most {schema.maximum}")
if isinstance(parsed, str):
if schema.min_length is not None and len(parsed) < schema.min_length:
raise ValueError(f"value is shorter than {schema.min_length}")
if schema.max_length is not None and len(parsed) > schema.max_length:
raise ValueError(f"value is longer than {schema.max_length}")
return parsed
def _schema_default(schema: Schema) -> Any:
return schema_example(schema)