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 @@
"""oVirt / RHV Engine API simulator application package."""
+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
+60
View File
@@ -0,0 +1,60 @@
"""Typed application configuration."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime settings loaded from environment variables and an optional `.env`."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
frozen=True,
)
app_name: str = "ovirt-api-simulator"
app_host: str = "0.0.0.0" # noqa: S104
# Internal listen port. Public Engine ports are published by api-gateway.
app_port: int = Field(default=8080, ge=1, le=65535)
database_url: SecretStr = SecretStr("postgresql://ovirt:ovirt@localhost:5432/ovirt_simulator")
db_pool_min_size: int = Field(default=1, ge=1, le=100)
db_pool_max_size: int = Field(default=10, ge=1, le=100)
db_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60)
db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
log_level: str = "INFO"
request_id_header: str = "X-Request-ID"
contract_snapshot: Path | None = None
compatibility_evidence: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
catalog_artifact_url_6: str = "stub://ovirt/4.3/api-contract"
catalog_artifact_url_7: str = "stub://ovirt/4.4/api-contract"
catalog_artifact_url_8: str = "stub://ovirt/4.5/api-contract"
catalog_artifact_url_9: str = "stub://ovirt/master/api-contract"
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
task_worker_concurrency: int = Field(default=2, ge=1, le=32)
task_lease_seconds: float = Field(default=30.0, gt=1, le=300)
simulation_time_scale: float = Field(default=10.0, gt=0, le=10000)
ovirt_series: str = "4.5"
def catalog_artifact_urls(self) -> dict[int, str]:
return {
6: self.catalog_artifact_url_6,
7: self.catalog_artifact_url_7,
8: self.catalog_artifact_url_8,
9: self.catalog_artifact_url_9,
}
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return the immutable process configuration."""
return Settings()
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL infrastructure."""
+16
View File
@@ -0,0 +1,16 @@
"""Apply configured database migrations."""
import asyncio
from app.config import get_settings
from app.db.migrations import migrate_url
async def run() -> None:
settings = get_settings()
count = await migrate_url(settings.database_url.get_secret_value())
print(f"applied {count} migration(s)")
if __name__ == "__main__":
asyncio.run(run())
+70
View File
@@ -0,0 +1,70 @@
"""Checksummed asynchronous PostgreSQL migration runner."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
import asyncpg # type: ignore[import-untyped]
from asyncpg import Connection
class MigrationError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Migration:
version: int
name: str
sql: str
checksum: str
def load_migrations(root: Path | None = None) -> tuple[Migration, ...]:
directory = root or Path(__file__).with_name("migrations")
migrations = []
for path in sorted(directory.glob("[0-9][0-9][0-9]_*.sql")):
version = int(path.name.split("_", 1)[0])
sql = path.read_text()
migrations.append(
Migration(version, path.stem, sql, hashlib.sha256(sql.encode()).hexdigest())
)
return tuple(migrations)
async def migrate(connection: Connection, migrations: tuple[Migration, ...] | None = None) -> int:
await connection.execute(
"""CREATE TABLE IF NOT EXISTS schema_migrations (
version integer PRIMARY KEY, name text NOT NULL, checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now())"""
)
applied = {
int(row["version"]): str(row["checksum"])
for row in await connection.fetch("SELECT version, checksum FROM schema_migrations")
}
count = 0
for migration in migrations or load_migrations():
if migration.version in applied:
if applied[migration.version] != migration.checksum:
raise MigrationError(f"migration {migration.version} checksum mismatch")
continue
async with connection.transaction():
await connection.execute(migration.sql)
await connection.execute(
"INSERT INTO schema_migrations(version, name, checksum) VALUES($1, $2, $3)",
migration.version,
migration.name,
migration.checksum,
)
count += 1
return count
async def migrate_url(database_url: str) -> int:
connection = await asyncpg.connect(database_url)
try:
return await migrate(connection)
finally:
await connection.close()
+325
View File
@@ -0,0 +1,325 @@
-- oVirt Engine identity, inventory, and generic API object store.
CREATE TABLE IF NOT EXISTS ov_domains (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS ov_users (
id uuid PRIMARY KEY,
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
name text NOT NULL,
password_hash text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
principal text NOT NULL DEFAULT '',
UNIQUE (domain_id, name)
);
CREATE TABLE IF NOT EXISTS ov_groups (
id uuid PRIMARY KEY,
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
name text NOT NULL,
UNIQUE (domain_id, name)
);
CREATE TABLE IF NOT EXISTS ov_roles (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
administrative boolean NOT NULL DEFAULT false
);
CREATE TABLE IF NOT EXISTS ov_permissions (
id uuid PRIMARY KEY,
role_id uuid NOT NULL REFERENCES ov_roles(id) ON DELETE CASCADE,
user_id uuid REFERENCES ov_users(id) ON DELETE CASCADE,
group_id uuid REFERENCES ov_groups(id) ON DELETE CASCADE,
object_type text NOT NULL DEFAULT 'system',
object_id uuid,
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL)
);
CREATE TABLE IF NOT EXISTS ov_tokens (
id text PRIMARY KEY,
user_id uuid NOT NULL REFERENCES ov_users(id) ON DELETE CASCADE,
scope text NOT NULL DEFAULT 'ovirt-app-api',
expires_at timestamptz NOT NULL,
issued_at timestamptz NOT NULL DEFAULT now(),
revoked boolean NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS ov_tokens_user_idx ON ov_tokens(user_id);
CREATE INDEX IF NOT EXISTS ov_tokens_expires_idx ON ov_tokens(expires_at);
CREATE TABLE IF NOT EXISTS ov_datacenters (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
local boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'up',
version_major integer NOT NULL DEFAULT 4,
version_minor integer NOT NULL DEFAULT 5,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ov_clusters (
id uuid PRIMARY KEY,
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
cpu_type text NOT NULL DEFAULT 'Intel Conroe Family',
version_major integer NOT NULL DEFAULT 4,
version_minor integer NOT NULL DEFAULT 5,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (datacenter_id, name)
);
CREATE TABLE IF NOT EXISTS ov_hosts (
id uuid PRIMARY KEY,
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
name text NOT NULL UNIQUE,
address text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'up',
type text NOT NULL DEFAULT 'rhel',
memory bigint NOT NULL DEFAULT 0,
cpu_cores integer NOT NULL DEFAULT 8,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ov_networks (
id uuid PRIMARY KEY,
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
vlan_id integer,
stp boolean NOT NULL DEFAULT false,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (datacenter_id, name)
);
CREATE TABLE IF NOT EXISTS ov_vnic_profiles (
id uuid PRIMARY KEY,
network_id uuid NOT NULL REFERENCES ov_networks(id) ON DELETE CASCADE,
name text NOT NULL,
pass_through boolean NOT NULL DEFAULT false,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (network_id, name)
);
CREATE TABLE IF NOT EXISTS ov_storage_domains (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
type text NOT NULL DEFAULT 'data',
storage_type text NOT NULL DEFAULT 'nfs',
status text NOT NULL DEFAULT 'active',
available bigint NOT NULL DEFAULT 0,
used bigint NOT NULL DEFAULT 0,
committed bigint NOT NULL DEFAULT 0,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ov_storage_domain_attachments (
id uuid PRIMARY KEY,
storage_domain_id uuid NOT NULL REFERENCES ov_storage_domains(id) ON DELETE CASCADE,
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'active',
UNIQUE (storage_domain_id, datacenter_id)
);
CREATE TABLE IF NOT EXISTS ov_storage_connections (
id uuid PRIMARY KEY,
type text NOT NULL DEFAULT 'nfs',
address text NOT NULL DEFAULT '',
path text NOT NULL DEFAULT '',
data jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS ov_templates (
id uuid PRIMARY KEY,
cluster_id uuid REFERENCES ov_clusters(id) ON DELETE SET NULL,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'ok',
memory bigint NOT NULL DEFAULT 1073741824,
cpu_sockets integer NOT NULL DEFAULT 1,
cpu_cores integer NOT NULL DEFAULT 1,
data jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS ov_vms (
id uuid PRIMARY KEY,
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
template_id uuid REFERENCES ov_templates(id) ON DELETE SET NULL,
name text NOT NULL,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'down',
memory bigint NOT NULL DEFAULT 1073741824,
cpu_sockets integer NOT NULL DEFAULT 1,
cpu_cores integer NOT NULL DEFAULT 1,
cpu_threads integer NOT NULL DEFAULT 1,
os_type text NOT NULL DEFAULT 'other',
type text NOT NULL DEFAULT 'server',
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (cluster_id, name)
);
CREATE INDEX IF NOT EXISTS ov_vms_status_idx ON ov_vms(status);
CREATE INDEX IF NOT EXISTS ov_vms_name_idx ON ov_vms(name);
CREATE TABLE IF NOT EXISTS ov_disks (
id uuid PRIMARY KEY,
name text NOT NULL,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'ok',
provisioned_size bigint NOT NULL DEFAULT 0,
actual_size bigint NOT NULL DEFAULT 0,
format text NOT NULL DEFAULT 'cow',
sparse boolean NOT NULL DEFAULT true,
shareable boolean NOT NULL DEFAULT false,
wipe_after_delete boolean NOT NULL DEFAULT false,
storage_domain_id uuid REFERENCES ov_storage_domains(id) ON DELETE SET NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ov_disk_attachments (
id uuid PRIMARY KEY,
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
disk_id uuid NOT NULL REFERENCES ov_disks(id) ON DELETE CASCADE,
active boolean NOT NULL DEFAULT true,
bootable boolean NOT NULL DEFAULT false,
interface text NOT NULL DEFAULT 'virtio_scsi',
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (vm_id, disk_id)
);
CREATE TABLE IF NOT EXISTS ov_nics (
id uuid PRIMARY KEY,
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
name text NOT NULL,
interface text NOT NULL DEFAULT 'virtio',
linked boolean NOT NULL DEFAULT true,
plugged boolean NOT NULL DEFAULT true,
mac_address text NOT NULL DEFAULT '',
vnic_profile_id uuid REFERENCES ov_vnic_profiles(id) ON DELETE SET NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (vm_id, name)
);
CREATE TABLE IF NOT EXISTS ov_snapshots (
id uuid PRIMARY KEY,
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'ok',
snapshot_type text NOT NULL DEFAULT 'user',
persist_memorystate boolean NOT NULL DEFAULT false,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ov_tags (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ov_tag_assignments (
id uuid PRIMARY KEY,
tag_id uuid NOT NULL REFERENCES ov_tags(id) ON DELETE CASCADE,
object_type text NOT NULL,
object_id uuid NOT NULL,
UNIQUE (tag_id, object_type, object_id)
);
CREATE TABLE IF NOT EXISTS ov_affinity_groups (
id uuid PRIMARY KEY,
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
enforcing boolean NOT NULL DEFAULT true,
positive boolean NOT NULL DEFAULT true,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (cluster_id, name)
);
CREATE TABLE IF NOT EXISTS ov_quotas (
id uuid PRIMARY KEY,
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
data jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (datacenter_id, name)
);
CREATE TABLE IF NOT EXISTS ov_bookmarks (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
value text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ov_events (
id bigserial PRIMARY KEY,
code integer NOT NULL DEFAULT 0,
severity text NOT NULL DEFAULT 'normal',
description text NOT NULL DEFAULT '',
time timestamptz NOT NULL DEFAULT now(),
user_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
vm_id uuid REFERENCES ov_vms(id) ON DELETE SET NULL,
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS ov_jobs (
id uuid PRIMARY KEY,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'started',
auto_cleared boolean NOT NULL DEFAULT true,
started timestamptz NOT NULL DEFAULT now(),
ended timestamptz,
owner_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS ov_job_steps (
id uuid PRIMARY KEY,
job_id uuid NOT NULL REFERENCES ov_jobs(id) ON DELETE CASCADE,
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'started',
type text NOT NULL DEFAULT 'validating',
number integer NOT NULL DEFAULT 1,
started timestamptz NOT NULL DEFAULT now(),
ended timestamptz,
data jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Generic store for surface-complete collections not given dedicated tables.
CREATE TABLE IF NOT EXISTS ov_api_objects (
id uuid PRIMARY KEY,
collection text NOT NULL,
name text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'ok',
parent_collection text,
parent_id uuid,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ov_api_objects_collection_idx ON ov_api_objects(collection);
CREATE INDEX IF NOT EXISTS ov_api_objects_parent_idx ON ov_api_objects(parent_collection, parent_id);
CREATE TABLE IF NOT EXISTS ov_demo_meta (
key text PRIMARY KEY,
value text NOT NULL
);
CREATE TABLE IF NOT EXISTS ov_runtime_meta (
key text PRIMARY KEY,
value text NOT NULL
);
+88
View File
@@ -0,0 +1,88 @@
"""Small typed asyncpg pool boundary."""
from __future__ import annotations
from typing import Protocol, Self, cast
import asyncpg # type: ignore[import-untyped]
from asyncpg import Pool
from app.config import Settings
from app.db.migrations import load_migrations
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
class Database(Protocol):
"""Application-facing database lifecycle and health interface."""
async def connect(self) -> None: ...
async def close(self) -> None: ...
async def is_ready(self) -> bool: ...
class AsyncpgDatabase:
"""Own an asyncpg pool without exposing it as global mutable state."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._pool: Pool | None = None
@property
def pool(self) -> Pool:
"""Return the initialized pool to repository factories."""
if self._pool is None:
message = "database pool is not initialized"
raise RuntimeError(message)
return self._pool
async def connect(self) -> None:
"""Create the pool and verify the first connection."""
if self._pool is not None:
return
settings = self._settings
pool = await asyncpg.create_pool(
dsn=settings.database_url.get_secret_value(),
min_size=settings.db_pool_min_size,
max_size=settings.db_pool_max_size,
timeout=settings.db_connect_timeout_seconds,
command_timeout=settings.db_command_timeout_seconds,
)
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
message = "asyncpg did not create a pool"
raise RuntimeError(message)
self._pool = cast(Pool, pool)
async def close(self) -> None:
"""Close all pooled connections; repeated close is safe."""
pool, self._pool = self._pool, None
if pool is not None:
await pool.close()
async def is_ready(self) -> bool:
"""Check connectivity and that all packaged migrations are applied."""
if self._pool is None:
return False
try:
return bool(
await self._pool.fetchval(
"""SELECT COALESCE(max(version), 0) >= $1
FROM schema_migrations""",
LATEST_SCHEMA_VERSION,
)
)
except asyncpg.PostgresError:
return False
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
await self.close()
+94
View File
@@ -0,0 +1,94 @@
"""Typed transactional helpers and stable database error mapping."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
import asyncpg # type: ignore[import-untyped]
from asyncpg import Connection, Pool
class DatabaseOperationError(RuntimeError):
"""Safe base error for repository operations."""
class ConflictError(DatabaseOperationError):
pass
class ReferenceError(DatabaseOperationError):
pass
class TransientDatabaseError(DatabaseOperationError):
pass
def map_database_error(error: asyncpg.PostgresError) -> DatabaseOperationError:
if isinstance(error, asyncpg.UniqueViolationError):
return ConflictError("database uniqueness constraint failed")
if isinstance(error, asyncpg.ForeignKeyViolationError):
return ReferenceError("database reference constraint failed")
if isinstance(
error,
asyncpg.SerializationError
| asyncpg.DeadlockDetectedError
| asyncpg.TooManyConnectionsError,
):
return TransientDatabaseError("transient database failure")
return DatabaseOperationError("database operation failed")
@asynccontextmanager
async def transaction(pool: Pool) -> AsyncIterator[Connection]:
async with pool.acquire() as connection:
try:
async with connection.transaction():
yield connection
except asyncpg.PostgresError as error:
raise map_database_error(error) from error
@asynccontextmanager
async def savepoint(connection: Connection) -> AsyncIterator[Connection]:
try:
async with connection.transaction():
yield connection
except asyncpg.PostgresError as error:
raise map_database_error(error) from error
def require_affected(status: str, expected: int = 1) -> None:
try:
affected = int(status.rsplit(" ", 1)[1])
except (IndexError, ValueError) as error:
raise DatabaseOperationError(f"unrecognized command status: {status}") from error
if affected != expected:
raise DatabaseOperationError(f"expected {expected} affected row(s), got {affected}")
@dataclass(frozen=True, slots=True)
class RetryPolicy:
attempts: int = 3
base_delay_seconds: float = 0.02
DEFAULT_RETRY_POLICY = RetryPolicy()
async def retry_transient[T](
operation: Callable[[], Awaitable[T]], policy: RetryPolicy = DEFAULT_RETRY_POLICY
) -> T:
if policy.attempts < 1:
raise ValueError("retry attempts must be positive")
for attempt in range(policy.attempts):
try:
return await operation()
except TransientDatabaseError:
if attempt + 1 == policy.attempts:
raise
await asyncio.sleep(policy.base_delay_seconds * (2**attempt))
raise RuntimeError("unreachable retry state")
+1
View File
@@ -0,0 +1 @@
"""Typed PostgreSQL repositories for simulation domain state."""
+97
View File
@@ -0,0 +1,97 @@
"""Typed resource persistence with explicit optimistic locking."""
from __future__ import annotations
import json
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, cast
from asyncpg import Pool # type: ignore[import-untyped]
from app.db.primitives import ConflictError, transaction
@dataclass(frozen=True, slots=True)
class ResourceRecord:
id: uuid.UUID
cluster_id: uuid.UUID
node: str
kind: str
external_id: str
state: dict[str, Any]
metadata: dict[str, Any]
version: int
def _json_object(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def _record(row: Mapping[str, object]) -> ResourceRecord:
return ResourceRecord(
id=cast(uuid.UUID, row["id"]),
cluster_id=cast(uuid.UUID, row["cluster_id"]),
node=str(row["node"]),
kind=str(row["kind"]),
external_id=str(row["external_id"]),
state=_json_object(row["state"]),
metadata=_json_object(row["metadata"]),
version=int(cast(int, row["version"])),
)
class ResourceRepository:
def __init__(self, pool: Pool) -> None:
self._pool = pool
async def list(
self, *, kind: str | None = None, node: str | None = None
) -> list[ResourceRecord]:
rows = await self._pool.fetch(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE ($1::text IS NULL OR r.kind=$1)
AND ($2::text IS NULL OR n.name=$2)
ORDER BY r.kind, r.external_id""",
kind,
node,
)
return [_record(row) for row in rows]
async def get(self, *, kind: str, external_id: str) -> ResourceRecord | None:
row = await self._pool.fetchrow(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind=$1 AND r.external_id=$2""",
kind,
external_id,
)
return None if row is None else _record(row)
async def update_state(
self,
resource_id: uuid.UUID,
*,
expected_version: int,
state: Mapping[str, object],
) -> ResourceRecord:
async with transaction(self._pool) as connection:
row = await connection.fetchrow(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2
RETURNING id, cluster_id,
(SELECT name FROM nodes WHERE id=resources.node_id) AS node,
kind, external_id, state, metadata, version""",
resource_id,
expected_version,
json.dumps(dict(state), sort_keys=True),
)
if row is None:
raise ConflictError("resource version conflict or resource missing")
return _record(row)
+14
View File
@@ -0,0 +1,14 @@
"""FastAPI dependency adapters."""
from __future__ import annotations
from fastapi import Request
from app.db.pool import Database
def get_database(request: Request) -> Database:
"""Resolve the lifespan-owned database from application state."""
database: Database = request.app.state.database
return database
+74
View File
@@ -0,0 +1,74 @@
"""Application resource ownership."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Protocol
from fastapi import FastAPI
from app.config import Settings
from app.db.pool import AsyncpgDatabase, Database
DatabaseFactory = Callable[[Settings], Database]
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
class LifespanWorker(Protocol):
async def run(self) -> None: ...
def stop(self) -> None: ...
WorkerFactory = Callable[[Database], LifespanWorker]
def create_lifespan(
settings: Settings,
database_factory: DatabaseFactory,
worker_factories: tuple[WorkerFactory, ...] = (),
) -> Lifespan:
"""Build a lifespan context so tests can inject a database implementation."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
database = database_factory(settings)
await database.connect()
app.state.database = database
if isinstance(database, AsyncpgDatabase):
from app.ovirt.demo_datacenter import DEMO_PROFILE
from app.ovirt.seed import seed_ovirt
from app.ovirt.settings import seed_engine_options
async with database.pool.acquire() as connection:
try:
profile = await connection.fetchval(
"SELECT value FROM ov_demo_meta WHERE key = 'profile'"
)
except Exception:
profile = None
if profile != DEMO_PROFILE:
await seed_ovirt(connection)
else:
# Keep Engine options current without wiping demo inventory.
await seed_engine_options(connection)
workers = tuple(factory(database) for factory in worker_factories)
worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers)
try:
yield
finally:
for worker in workers:
worker.stop()
if worker_tasks:
await asyncio.gather(*worker_tasks)
await database.close()
return lifespan
def default_database_factory(settings: Settings) -> Database:
"""Create the production asyncpg adapter."""
return AsyncpgDatabase(settings)
+40
View File
@@ -0,0 +1,40 @@
"""Structured logging configuration with safe JSON output."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
class JsonFormatter(logging.Formatter):
"""Serialize standard records and selected structured attributes as JSON."""
_fields = ("request_id", "method", "path", "status", "duration_ms")
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for field in self._fields:
value = getattr(record, field, None)
if value is not None:
payload[field] = value
if record.exc_info is not None:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def configure_logging(level: str) -> None:
"""Configure the root logger once for the process."""
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())
+46
View File
@@ -0,0 +1,46 @@
"""FastAPI application factory and ASGI entry point."""
from __future__ import annotations
import asyncio
import os
from fastapi import FastAPI
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
from app.api.middleware import RequestContextMiddleware
from app.api.openapi import openapi_tag_metadata
from app.config import Settings, get_settings
from app.lifespan import DatabaseFactory, create_lifespan, default_database_factory
from app.logging import configure_logging
from app.observability.health import router as health_router
from app.ovirt.mount import mount_ovirt_routes
from app.web.routes import router as web_router
def create_app(
settings: Settings | None = None,
database_factory: DatabaseFactory = default_database_factory,
) -> FastAPI:
"""Create an isolated application instance."""
resolved = settings or get_settings()
configure_logging(resolved.log_level)
app = FastAPI(
title=resolved.app_name,
version="0.1.0",
openapi_tags=openapi_tag_metadata(),
lifespan=create_lifespan(resolved, database_factory, ()),
)
app.state.settings = resolved
app.state.contract_swap_lock = asyncio.Lock()
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_exception_handler(ApiError, api_error_handler)
app.include_router(web_router)
app.include_router(health_router)
mount_ovirt_routes(app, series=os.environ.get("OVIRT_SERIES", "4.5"))
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""Health, metrics, and tracing adapters."""
+37
View File
@@ -0,0 +1,37 @@
"""Kubernetes-compatible health endpoints."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Response, status
from pydantic import BaseModel
from app.db.pool import Database
from app.dependencies import get_database
router = APIRouter(prefix="/health", tags=["Simulator"])
class HealthResponse(BaseModel):
status: str
@router.get("/live", response_model=HealthResponse)
async def live() -> HealthResponse:
"""Report process liveness without checking dependencies."""
return HealthResponse(status="ok")
@router.get("/ready", response_model=HealthResponse)
async def ready(
response: Response,
database: Annotated[Database, Depends(get_database)],
) -> HealthResponse:
"""Report whether the required database dependency is usable."""
if await database.is_ready():
return HealthResponse(status="ok")
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return HealthResponse(status="unavailable")
+9
View File
@@ -0,0 +1,9 @@
"""oVirt / RHV Engine API simulator domain package."""
__all__ = ["mount_ovirt_routes"]
def mount_ovirt_routes(*args, **kwargs): # lazy re-export
from app.ovirt.mount import mount_ovirt_routes as _mount
return _mount(*args, **kwargs)
+220
View File
@@ -0,0 +1,220 @@
"""oVirt Engine SSO OAuth2 + Basic auth + session cookies."""
from __future__ import annotations
import base64
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from asyncpg import Connection
from app.ovirt.errors import OVirtError
from app.ovirt.settings import (
OPT_BASIC_SESSION_TTL_SECONDS,
OPT_DEFAULT_API_SCOPE,
OPT_DEFAULT_AUTH_DOMAIN,
OPT_DEFAULT_TOKEN_TYPE,
OPT_DEFAULT_USER_ROLE,
OPT_OAUTH_TOKEN_TTL_SECONDS,
option_int,
option_value,
)
from app.security.auth import verify_secret
@dataclass(frozen=True)
class AuthContext:
token_id: str
user_id: UUID
user_name: str
domain: str
roles: tuple[str, ...]
expires_at: datetime
is_admin: bool
scope: str
async def authenticate_password(
conn: Connection,
username: str,
password: str,
) -> dict[str, Any]:
"""Resolve user@domain credentials."""
name, _, domain = username.partition("@")
if not domain:
domain = await option_value(conn, OPT_DEFAULT_AUTH_DOMAIN)
row = await conn.fetchrow(
"""SELECT u.id, u.name, u.password_hash, u.enabled, d.name AS domain_name
FROM ov_users u
JOIN ov_domains d ON d.id = u.domain_id
WHERE u.name = $1 AND d.name = $2""",
name,
domain,
)
if row is None or not row["enabled"] or not verify_secret(password, row["password_hash"]):
raise OVirtError("Unauthorized", "Incorrect credentials", status_code=401)
return dict(row)
async def issue_oauth_token(
conn: Connection,
*,
username: str,
password: str,
scope: str | None = None,
ttl_seconds: int | None = None,
) -> dict[str, Any]:
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
token_type = await option_value(conn, OPT_DEFAULT_TOKEN_TYPE)
if ttl_seconds is None:
ttl_seconds = await option_int(conn, OPT_OAUTH_TOKEN_TTL_SECONDS)
scope = scope or default_scope
if scope and default_scope not in scope.split():
raise OVirtError("Unauthorized", "Invalid scope", status_code=400)
user = await authenticate_password(conn, username, password)
token = secrets.token_urlsafe(32)
now = datetime.now(UTC)
expires = now + timedelta(seconds=ttl_seconds)
await conn.execute(
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
VALUES($1, $2, $3, $4, $5, false)""",
token,
user["id"],
scope,
expires,
now,
)
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
return {
"access_token": row["id"],
"token_type": token_type,
"scope": row["scope"],
"exp": int(row["expires_at"].timestamp()),
}
async def validate_bearer(conn: Connection, token: str) -> AuthContext:
if not token:
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
row = await conn.fetchrow(
"""SELECT t.id, t.user_id, t.expires_at, t.revoked, t.scope,
u.name AS user_name, d.name AS domain_name
FROM ov_tokens t
JOIN ov_users u ON u.id = t.user_id
JOIN ov_domains d ON d.id = u.domain_id
WHERE t.id = $1""",
token,
)
if row is None or row["revoked"]:
raise OVirtError("Unauthorized", "Invalid token", status_code=401)
expires = row["expires_at"]
if expires.tzinfo is None:
expires = expires.replace(tzinfo=UTC)
if expires <= datetime.now(UTC):
raise OVirtError("Unauthorized", "Token has expired", status_code=401)
roles = await _roles_for_user(conn, row["user_id"])
return AuthContext(
token_id=str(row["id"]),
user_id=row["user_id"],
user_name=str(row["user_name"]),
domain=str(row["domain_name"]),
roles=roles,
expires_at=expires,
is_admin="SuperUser" in roles or "admin" in roles,
scope=str(row["scope"] or ""),
)
async def validate_basic(conn: Connection, header_value: str) -> AuthContext:
try:
encoded = header_value.split(" ", 1)[1].strip()
decoded = base64.b64decode(encoded).decode("utf-8")
username, _, password = decoded.partition(":")
except Exception as exc:
raise OVirtError("Unauthorized", "Malformed basic auth", status_code=401) from exc
user = await authenticate_password(conn, username, password)
token = secrets.token_urlsafe(24)
now = datetime.now(UTC)
ttl = await option_int(conn, OPT_BASIC_SESSION_TTL_SECONDS)
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
expires = now + timedelta(seconds=ttl)
await conn.execute(
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
VALUES($1, $2, $3, $4, $5, false)
ON CONFLICT (id) DO NOTHING""",
token,
user["id"],
default_scope,
expires,
now,
)
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
roles = await _roles_for_user(conn, user["id"])
return AuthContext(
token_id=str(row["id"]),
user_id=user["id"],
user_name=str(user["name"]),
domain=str(user["domain_name"]),
roles=roles,
expires_at=row["expires_at"] if row["expires_at"].tzinfo else row["expires_at"].replace(tzinfo=UTC),
is_admin="SuperUser" in roles or "admin" in roles,
scope=str(row["scope"] or default_scope),
)
async def _roles_for_user(conn: Connection, user_id: UUID) -> tuple[str, ...]:
rows = await conn.fetch(
"""SELECT r.name FROM ov_permissions p
JOIN ov_roles r ON r.id = p.role_id
WHERE p.user_id = $1""",
user_id,
)
names = [str(r["name"]) for r in rows]
if not names:
default_role = await option_value(conn, OPT_DEFAULT_USER_ROLE)
exists = await conn.fetchval("SELECT 1 FROM ov_roles WHERE name=$1", default_role)
if exists:
names = [default_role]
return tuple(names)
def extract_auth(headers: dict[str, str]) -> tuple[str, str] | None:
"""Return ('bearer'|'basic'|'session', credential) or None."""
lower = {k.lower(): v for k, v in headers.items()}
auth = lower.get("authorization", "")
if auth.lower().startswith("bearer "):
return "bearer", auth.split(" ", 1)[1].strip()
if auth.lower().startswith("basic "):
return "basic", auth
session = lower.get("prefer") or ""
if "jsessionid" in lower:
return "session", lower["jsessionid"]
cookie = lower.get("cookie", "")
for part in cookie.split(";"):
part = part.strip()
if part.lower().startswith("jsessionid="):
return "session", part.split("=", 1)[1]
if part.lower().startswith("ovirt_token="):
return "bearer", part.split("=", 1)[1]
if session.lower().startswith("persistent-auth"):
return None
return None
async def resolve_request_auth(conn: Connection, headers: dict[str, str]) -> AuthContext:
kind = extract_auth(headers)
if kind is None:
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
mode, credential = kind
if mode == "bearer":
return await validate_bearer(conn, credential)
if mode == "basic":
return await validate_basic(conn, credential)
if mode == "session":
return await validate_bearer(conn, credential)
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
+174
View File
@@ -0,0 +1,174 @@
"""Resolve oVirt Engine series contract pack locations."""
from __future__ import annotations
import json
import os
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.ovirt.opspec import OperationSpec, SeriesPack
_SERIES_MAJOR = {
"3.0": 30,
"3.1": 31,
"3.2": 32,
"3.3": 33,
"3.4": 34,
"3.5": 35,
"3.6": 36,
"4.3": 43,
"4.4": 44,
"4.5": 45,
"master": 50,
}
_MAJOR_SERIES = {v: k for k, v in _SERIES_MAJOR.items()}
def contracts_root() -> Path:
"""Locate ``contracts/ovirt`` for source, wheel, or Docker layouts."""
env = os.environ.get("OVIRT_CONTRACTS_ROOT")
if env:
return Path(env)
here = Path(__file__).resolve()
candidates = [
here.parents[2] / "contracts" / "ovirt", # repo checkout or site-packages
Path("/app/contracts/ovirt"), # runtime image WORKDIR layout
Path.cwd() / "contracts" / "ovirt",
]
for path in candidates:
if path.is_dir():
return path
return candidates[0]
def series_for_major(major: int) -> str:
return _MAJOR_SERIES.get(major, "4.5")
def major_for_series(series: str) -> int:
return _SERIES_MAJOR.get(series.lower(), 45)
def list_series() -> list[dict[str, Any]]:
root = contracts_root()
if not root.exists():
return []
result: list[dict[str, Any]] = []
for path in sorted(root.iterdir()):
man = path / "manifest.json"
if not man.is_file():
continue
data = json.loads(man.read_text())
result.append(
{
"series": data.get("series", path.name),
"major": data.get("major", major_for_series(path.name)),
"api_version": data.get("api_version", "4"),
"operation_count": data.get("operation_count", 0),
"service_count": data.get("service_count", 1),
"checksum": data.get("checksum", ""),
"generated_at": data.get("generated_at", ""),
"deltas": data.get("deltas", {}),
"product": data.get("product", {}),
}
)
return result
def _op_from_dict(raw: dict[str, Any]) -> OperationSpec:
return OperationSpec(
operation_id=str(raw["operation_id"]),
method=raw["method"],
path=str(raw["path"]),
resource_type=str(raw.get("resource_type") or "object"),
collection_key=str(raw.get("collection_key") or "objects"),
element=str(raw.get("element") or raw.get("resource_type") or "object"),
kind=str(raw.get("kind") or "collection"),
search=bool(raw.get("search", False)),
introduced_in=str(raw.get("introduced_in") or "3.0"),
requires_auth=bool(raw.get("requires_auth", True)),
status_code=int(raw.get("status_code") or 200),
create_status=int(raw.get("create_status") or 201),
notes=str(raw.get("notes") or ""),
response_fixture=raw.get("response_fixture"),
)
def load_series_pack(series: str) -> SeriesPack:
series = series.lower()
series_dir = contracts_root() / series
man_path = series_dir / "manifest.json"
api_path = series_dir / "api.json"
if not man_path.is_file() or not api_path.is_file():
raise FileNotFoundError(f"oVirt contract pack not found: {series_dir}")
man = json.loads(man_path.read_text())
data = json.loads(api_path.read_text())
ops = [_op_from_dict(raw) for raw in data.get("operations") or []]
return SeriesPack(
series=str(man.get("series", series)),
api_version=str(man.get("api_version") or data.get("api_version") or "4"),
major=int(man.get("major") or major_for_series(series)),
operations=ops,
product=dict(man.get("product") or data.get("product") or {}),
entry_point_links=list(man.get("entry_point_links") or []),
checksum=str(man.get("checksum") or ""),
)
@dataclass
class ContractRuntime:
series: str = "4.5"
pack: SeriesPack | None = None
_lock: threading.RLock = field(default_factory=threading.RLock)
def reload(self, series: str | None = None) -> dict[str, Any]:
with self._lock:
target = (series or self.series).lower()
self.pack = load_series_pack(target)
self.series = target
return self.summary()
def summary(self) -> dict[str, Any]:
with self._lock:
pack = self.pack
if pack is None:
return {
"series": self.series,
"operation_count": 0,
"major": major_for_series(self.series),
}
return {
"series": pack.series,
"major": pack.major,
"api_version": pack.api_version,
"operation_count": pack.operation_count(),
"service_count": 1,
"checksum": pack.checksum,
"product": pack.product,
"deltas": next(
(s.get("deltas") for s in list_series() if s["series"] == pack.series),
{},
),
}
_RUNTIME = ContractRuntime()
def get_runtime() -> ContractRuntime:
return _RUNTIME
def ensure_loaded(series: str = "4.5") -> ContractRuntime:
rt = get_runtime()
if rt.pack is None:
try:
rt.reload(series)
except FileNotFoundError:
pass
return rt
+436
View File
@@ -0,0 +1,436 @@
"""Large demo datacenter seed (~1000 VMs + full inventory)."""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.ovirt.ids import stable_id
from app.ovirt.seed import DEMO_PROFILE, clear_ovirt_state, seed_ovirt
from app.security.auth import hash_secret
DEMO_VM_COUNT = 1000
async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
"""Replace state with a multi-DC demo inventory including ~1000 VMs."""
await clear_ovirt_state(conn)
domain_id = stable_id("domain", "internal")
await conn.execute("INSERT INTO ov_domains(id, name) VALUES($1,'internal')", domain_id)
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
role_super = stable_id("role", "SuperUser")
role_user = stable_id("role", "UserRole")
role_cluster = stable_id("role", "ClusterAdmin")
for rid, name, admin in (
(role_super, "SuperUser", True),
(role_user, "UserRole", False),
(role_cluster, "ClusterAdmin", True),
(stable_id("role", "TemplateAdmin"), "TemplateAdmin", True),
(stable_id("role", "StorageAdmin"), "StorageAdmin", True),
):
await conn.execute(
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
rid,
name,
admin,
)
users = {}
for uname, role in (
("admin", role_super),
("ops", role_cluster),
("developer", role_user),
("demo", role_user),
):
uid = stable_id("user", uname)
users[uname] = uid
await conn.execute(
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
VALUES($1,$2,$3,$4,true,$5)""",
uid,
domain_id,
uname,
pwd,
f"{uname}@internal",
)
await conn.execute(
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
VALUES($1,$2,$3,'system')""",
stable_id("perm", uname),
role,
uid,
)
await conn.execute(
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
stable_id("group", "engine-admins"),
domain_id,
)
for gname in ("developers", "operators", "readers"):
await conn.execute(
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)",
stable_id("group", gname),
domain_id,
gname,
)
# 3 datacenters, multiple clusters/hosts/storage/networks
dc_specs = [
("dc-prod", "Production", False, 4, 5),
("dc-stage", "Staging", False, 4, 4),
("dc-edge", "Edge", True, 4, 3),
]
clusters: list[tuple[Any, Any, str]] = []
hosts: list[Any] = []
networks: list[Any] = []
profiles: list[Any] = []
storage_domains: list[Any] = []
storage_types = ["nfs", "iscsi", "fcp", "localfs"]
for dc_key, dc_name, local, maj, minor in dc_specs:
dc_id = stable_id("dc", dc_key)
await conn.execute(
"""INSERT INTO ov_datacenters(id, name, description, local, status, version_major, version_minor)
VALUES($1,$2,$3,$4,'up',$5,$6)""",
dc_id,
dc_name,
f"{dc_name} datacenter",
local,
maj,
minor,
)
await conn.execute(
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
VALUES($1,$2,'Default','Default quota')""",
stable_id("quota", dc_key),
dc_id,
)
for ci in range(2):
cname = f"{dc_key}-cluster-{ci+1}"
cid = stable_id("cluster", cname)
clusters.append((cid, dc_id, cname))
await conn.execute(
"""INSERT INTO ov_clusters(id, datacenter_id, name, description, version_major, version_minor)
VALUES($1,$2,$3,$4,$5,$6)""",
cid,
dc_id,
cname,
f"Cluster {ci+1} in {dc_name}",
maj,
minor,
)
await conn.execute(
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
VALUES($1,$2,'web-affinity',true,true)""",
stable_id("ag", cname),
cid,
)
for hi in range(4):
hname = f"{cname}-host-{hi+1:02d}"
hid = stable_id("host", hname)
hosts.append(hid)
await conn.execute(
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores, type)
VALUES($1,$2,$3,$4,'up',$5,32,'rhel')""",
hid,
cid,
hname,
f"10.{dc_specs.index((dc_key, dc_name, local, maj, minor))+10}.{ci+1}.{hi+10}",
(256 + hi * 32) * 1024**3,
)
# networks
for nname, vlan in (("ovirtmgmt", None), ("vm-net", 100), ("storage-net", 200)):
nid = stable_id("net", dc_key, nname)
networks.append(nid)
await conn.execute(
"""INSERT INTO ov_networks(id, datacenter_id, name, description, vlan_id)
VALUES($1,$2,$3,$4,$5)""",
nid,
dc_id,
nname if nname != "ovirtmgmt" else f"{dc_key}-ovirtmgmt" if dc_key != "dc-prod" else "ovirtmgmt",
f"{nname} in {dc_name}",
vlan,
)
pid = stable_id("vnic", dc_key, nname)
profiles.append(pid)
await conn.execute(
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,$3)",
pid,
nid,
nname,
)
# storage domains
for si, stype in enumerate(storage_types):
sname = f"{dc_key}-{stype}-{si+1}"
sid = stable_id("sd", sname)
storage_domains.append(sid)
await conn.execute(
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
VALUES($1,$2,'data',$3,'active',$4,$5)""",
sid,
sname,
stype,
(5 + si) * 1024**4,
si * 200 * 1024**3,
)
await conn.execute(
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
VALUES($1,$2,$3,'active')""",
stable_id("sda", sname),
sid,
dc_id,
)
await conn.execute(
"""INSERT INTO ov_storage_connections(id, type, address, path)
VALUES($1,$2,$3,$4)""",
stable_id("sc", sname),
stype if stype != "localfs" else "localfs",
f"storage-{si}.lab.local",
f"/export/{sname}",
)
blank_id = stable_id("template", "Blank")
await conn.execute(
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
VALUES($1,$2,'Blank','Blank template','ok',$3,1,1)""",
blank_id,
clusters[0][0],
1024**3,
)
for tname, mem, cores in (
("rhel8-base", 4 * 1024**3, 2),
("rhel9-base", 4 * 1024**3, 2),
("win2022-base", 8 * 1024**3, 4),
("ubuntu2204-base", 2 * 1024**3, 2),
):
await conn.execute(
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
VALUES($1,$2,$3,$4,'ok',$5,1,$6)""",
stable_id("template", tname),
clusters[0][0],
tname,
f"Template {tname}",
mem,
cores,
)
# ~1000 VMs spread across clusters
statuses = ["up", "up", "up", "down", "down", "suspended", "powering_up"]
os_types = ["rhel_8x64", "rhel_9x64", "ubuntu_22_04", "windows_2022", "other"]
default_profile = profiles[0]
default_sd = storage_domains[0]
vm_rows = []
disk_rows = []
da_rows = []
nic_rows = []
snap_rows = []
for i in range(DEMO_VM_COUNT):
cluster_id, _dc, cname = clusters[i % len(clusters)]
host_id = hosts[i % len(hosts)] if i % 3 != 0 else None
status = statuses[i % len(statuses)]
if status == "down":
host_id = None
name = f"vm-{i+1:04d}"
vm_id = stable_id("vm", name)
memory = (1 + (i % 8)) * 1024**3
cores = 1 + (i % 8)
vm_rows.append(
(
vm_id,
cluster_id,
blank_id,
name,
f"Demo VM {i+1}",
status,
memory,
1,
cores,
1,
os_types[i % len(os_types)],
"server" if i % 5 else "desktop",
host_id,
)
)
disk_id = stable_id("disk", name)
size = (10 + (i % 50)) * 1024**3
disk_rows.append(
(disk_id, f"{name}_Disk1", size, size, storage_domains[i % len(storage_domains)])
)
da_rows.append((stable_id("da", name), vm_id, disk_id, True))
nic_rows.append(
(
stable_id("nic", name),
vm_id,
"nic1",
f"00:1a:4a:{(i >> 16) & 0xFF:02x}:{(i >> 8) & 0xFF:02x}:{i & 0xFF:02x}",
profiles[i % len(profiles)] if profiles else default_profile,
)
)
if i % 7 == 0:
snap_rows.append(
(stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok")
)
# Batch insert VMs
await conn.executemany(
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type, host_id)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)""",
vm_rows,
)
await conn.executemany(
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
VALUES($1,$2,$3,$4,$5)""",
disk_rows,
)
await conn.executemany(
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
VALUES($1,$2,$3,$4)""",
da_rows,
)
await conn.executemany(
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
VALUES($1,$2,$3,$4,$5)""",
nic_rows,
)
if snap_rows:
await conn.executemany(
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
VALUES($1,$2,$3,$4)""",
snap_rows,
)
# Tags, bookmarks, events, jobs, surface objects
for tname in ("production", "web", "database", "batch", "gpu"):
tid = stable_id("tag", tname)
await conn.execute(
"INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)",
tid,
tname,
f"Tag {tname}",
)
for i in range(0, min(50, DEMO_VM_COUNT), 10):
await conn.execute(
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
stable_id("ta", tname, str(i)),
tid,
stable_id("vm", f"vm-{i+1:04d}"),
)
await conn.execute(
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'UpVMs','Vms: status=up')",
stable_id("bm", "UpVMs"),
)
for i in range(50):
await conn.execute(
"""INSERT INTO ov_events(code, severity, description, user_id)
VALUES($1,$2,$3,$4)""",
1000 + i,
"normal" if i % 4 else "warning",
f"Demo event {i}",
users["admin"],
)
for i in range(20):
jid = stable_id("job", str(i))
await conn.execute(
"""INSERT INTO ov_jobs(id, description, status, owner_id)
VALUES($1,$2,'finished',$3)""",
jid,
f"Demo job {i}",
users["admin"],
)
await conn.execute(
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
VALUES($1,$2,$3,'finished','executing',1)""",
stable_id("step", str(i)),
jid,
f"Step for job {i}",
)
for collection, names in (
("instancetypes", ["Tiny", "Small", "Medium", "Large", "XLarge"]),
("macpools", ["Default", "Secondary"]),
("schedulingpolicies", ["evenly_distributed", "power_saving", "vm_evenly_distributed"]),
("schedulingpolicyunits", ["EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"]),
("clusterlevels", ["4.3", "4.4", "4.5"]),
("icons", ["default", "custom"]),
("operatingsystems", ["rhel_8x64", "rhel_9x64", "windows_2022", "ubuntu_22_04"]),
("networkfilters", ["vdsm-no-mac-spoofing"]),
("vmpools", ["web-pool", "batch-pool"]),
("affinitylabels", ["label-a", "label-b"]),
("katelloerrata", ["RHSA-2024:0001", "RHBA-2024:0002"]),
("externalhostproviders", ["foreman-lab"]),
("openstacknetworkproviders", ["ovn-provider"]),
("openstackimageproviders", ["glance-lab"]),
("openstackvolumeproviders", ["cinder-lab"]),
("imagetransfers", ["transfer-1"]),
):
for name in names:
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
VALUES($1,$2,$3,'ok',$4::jsonb)""",
stable_id("obj", collection, name),
collection,
name,
json.dumps({"name": name, "description": f"demo {collection}"}),
)
from app.ovirt.settings import seed_engine_options
await seed_engine_options(conn)
from app.ovirt.seed_nested import seed_nested_for_inventory
dc_ids = [stable_id("dc", key) for key, *_ in dc_specs]
cluster_ids = [c[0] for c in clusters]
template_ids = [
blank_id,
*[
stable_id("template", n)
for n in ("rhel8-base", "rhel9-base", "win2022-base", "ubuntu2204-base")
],
]
tag_ids = [stable_id("tag", t) for t in ("production", "web", "database", "batch", "gpu")]
await seed_nested_for_inventory(
conn,
admin_user_id=users["admin"],
role_user_id=role_user,
datacenter_ids=dc_ids,
cluster_ids=cluster_ids,
host_ids=list(hosts),
network_ids=list(networks),
storage_domain_ids=list(storage_domains),
template_ids=template_ids,
vm_ids=[stable_id("vm", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
disk_ids=[stable_id("disk", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
tag_ids=tag_ids,
user_ids=list(users.values()),
group_ids=[
stable_id("group", n)
for n in ("engine-admins", "developers", "operators", "readers")
],
)
await conn.execute(
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", DEMO_PROFILE
)
return {
"profile": DEMO_PROFILE,
"vms": DEMO_VM_COUNT,
"hosts": len(hosts),
"datacenters": len(dc_specs),
"clusters": len(clusters),
"storage_domains": len(storage_domains),
"networks": len(networks),
}
# Re-export for web routes
__all__ = ["DEMO_PROFILE", "DEMO_VM_COUNT", "clear_ovirt_state", "seed_ovirt", "seed_ovirt_demo"]
+22
View File
@@ -0,0 +1,22 @@
"""FastAPI dependencies for oVirt routes."""
from __future__ import annotations
from fastapi import Request
from app.db.pool import AsyncpgDatabase
from app.ovirt.auth import AuthContext, resolve_request_auth
from app.ovirt.errors import OVirtError
def get_db(request: Request) -> AsyncpgDatabase:
db = getattr(request.app.state, "database", None)
if db is None:
raise OVirtError("ServiceUnavailable", "Database not ready", status_code=503)
return db # type: ignore[return-value]
async def require_auth(request: Request) -> AuthContext:
db = get_db(request)
async with db.pool.acquire() as conn:
return await resolve_request_auth(conn, dict(request.headers))
+68
View File
@@ -0,0 +1,68 @@
"""oVirt Engine fault responses (XML/JSON)."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse, Response
class OVirtError(Exception):
def __init__(
self,
reason: str,
detail: str,
*,
status_code: int = 400,
code: str | None = None,
) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
self.status_code = status_code
self.code = code or reason
def _wants_xml(request: Request) -> bool:
accept = (request.headers.get("accept") or "").lower()
content = (request.headers.get("content-type") or "").lower()
if "application/xml" in accept or "text/xml" in accept:
return True
if "json" in accept:
return False
if "xml" in content:
return True
# Engine default is XML when Accept is omitted / */*
return "json" not in accept
def fault_body(error: OVirtError, *, as_xml: bool) -> str | dict[str, Any]:
if as_xml:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f"<fault><reason>{_esc(error.reason)}</reason>"
f"<detail>{_esc(error.detail)}</detail></fault>"
)
return {"fault": {"reason": error.reason, "detail": error.detail}}
def _esc(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
async def ovirt_error_handler(request: Request, exc: OVirtError) -> Response:
as_xml = _wants_xml(request)
body = fault_body(exc, as_xml=as_xml)
if as_xml:
return Response(
content=str(body),
status_code=exc.status_code,
media_type="application/xml",
)
return JSONResponse(content=body, status_code=exc.status_code)
+15
View File
@@ -0,0 +1,15 @@
"""Deterministic UUIDs for seed data."""
from __future__ import annotations
from uuid import UUID, uuid5
NAMESPACE = UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
def stable_id(*parts: str) -> UUID:
return uuid5(NAMESPACE, ":".join(parts))
def stable_str(*parts: str) -> str:
return str(stable_id(*parts))
+100
View File
@@ -0,0 +1,100 @@
"""Async job/task helpers for Engine actions — rows are always loaded from Postgres."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import Request, Response
from app.ovirt.repr import action_entity, job_entity
from app.ovirt.serialize import respond
from app.ovirt.settings import (
OPT_DEFAULT_ACTION_STATUS,
OPT_DEFAULT_JOB_STATUS_COMPLETE,
OPT_DEFAULT_JOB_STATUS_STARTED,
OPT_DEFAULT_JOB_STEP_TYPE,
option_value,
)
async def create_job(
conn: Connection,
*,
description: str,
owner_id: Any = None,
auto_complete: bool = True,
action_status: str | None = None,
) -> dict[str, Any]:
job_id = uuid4()
now = datetime.now(UTC)
finished = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
started = await option_value(conn, OPT_DEFAULT_JOB_STATUS_STARTED)
status = finished if auto_complete else started
ended = now if auto_complete else None
if action_status is None:
action_status = await option_value(conn, OPT_DEFAULT_ACTION_STATUS)
step_type = await option_value(conn, OPT_DEFAULT_JOB_STEP_TYPE)
await conn.execute(
"""INSERT INTO ov_jobs(id, description, status, started, ended, owner_id, data)
VALUES($1, $2, $3, $4, $5, $6, $7::jsonb)""",
job_id,
description,
status,
now,
ended,
owner_id,
json.dumps({"action_status": action_status}),
)
step_id = uuid4()
await conn.execute(
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number, started, ended)
VALUES($1, $2, $3, $4, $5, 1, $6, $7)""",
step_id,
job_id,
description,
status,
step_type,
now,
ended,
)
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1", job_id)
return job_entity(row)
async def complete_job(conn: Connection, job_id: str, *, status: str | None = None) -> None:
now = datetime.now(UTC)
if status is None:
status = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
await conn.execute(
"UPDATE ov_jobs SET status=$2, ended=$3 WHERE id=$1::uuid",
job_id,
status,
now,
)
await conn.execute(
"UPDATE ov_job_steps SET status=$2, ended=$3 WHERE job_id=$1::uuid",
job_id,
status,
now,
)
async def respond_action(
request: Request,
conn: Connection,
*,
description: str,
owner_id: Any = None,
auto_complete: bool = True,
) -> Response:
"""Persist a job and return an action entity built solely from the DB row."""
job = await create_job(
conn, description=description, owner_id=owner_id, auto_complete=auto_complete
)
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1::uuid", job["id"])
return respond(request, element="action", data=action_entity(row))
+37
View File
@@ -0,0 +1,37 @@
"""Mount oVirt Engine API + SSO onto the FastAPI application."""
from __future__ import annotations
from fastapi import FastAPI
from app.ovirt.contract_loader import ensure_loaded
from app.ovirt.errors import OVirtError, ovirt_error_handler
from app.ovirt.registry import register_ovirt_contract_routes
from app.ovirt.routes import engine, sso
def mount_ovirt_routes(app: FastAPI, *, series: str = "4.5") -> None:
"""Register Engine REST API, SSO, and load the active series pack.
Contract operations are registered as individual OpenAPI routes.
Catch-all handlers remain as a hidden fallback.
"""
app.add_exception_handler(OVirtError, ovirt_error_handler)
app.include_router(sso.router)
rt = ensure_loaded(series)
try:
summary = rt.reload(series)
except FileNotFoundError:
summary = {"operation_count": 0, "series": series}
registered = 0
if rt.pack is not None:
registered = register_ovirt_contract_routes(app, rt.pack)
# Fallback catch-all after specific contract routes (first match wins).
app.include_router(engine.router)
app.state.ovirt_series = series
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
app.state.runtime_version = f"ovirt-{series}"
+40
View File
@@ -0,0 +1,40 @@
"""Contract operation specifications for Engine API packs."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]
@dataclass(frozen=True)
class OperationSpec:
operation_id: str
method: HttpMethod
path: str
resource_type: str
collection_key: str
element: str
kind: str = "collection"
search: bool = False
introduced_in: str = "3.0"
requires_auth: bool = True
status_code: int = 200
create_status: int = 201
notes: str = ""
response_fixture: Any | None = None
@dataclass
class SeriesPack:
series: str
api_version: str
major: int
operations: list[OperationSpec] = field(default_factory=list)
product: dict[str, str] = field(default_factory=dict)
entry_point_links: list[dict[str, str]] = field(default_factory=list)
checksum: str = ""
def operation_count(self) -> int:
return len(self.operations)
+98
View File
@@ -0,0 +1,98 @@
"""Register each Engine contract operation as its own FastAPI route."""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from enum import Enum
from typing import cast
from fastapi import FastAPI, Request
from fastapi.responses import Response
from app.api.openapi import contract_openapi_tags
from app.ovirt.opspec import OperationSpec, SeriesPack
Endpoint = Callable[[Request], Awaitable[Response]]
_NON_ALNUM = re.compile(r"[^a-zA-Z0-9_]+")
def clear_ovirt_contract_routes(app: FastAPI) -> None:
"""Drop contract + Engine fallback routes before a remount."""
app.router.routes = [
route for route in app.router.routes if not _is_swappable_ovirt_route(route)
]
app.openapi_schema = None
def register_ovirt_contract_routes(app: FastAPI, pack: SeriesPack) -> int:
"""Register one FastAPI route per contract operation.
Paths are taken from the pack as-is (including ``/v3`` / ``/v4`` variants).
Returns the number of routes added.
"""
seen: set[tuple[str, str]] = set()
registered = 0
for op in pack.operations:
method = op.method.upper()
path = _normalize_route_path(op.path)
key = (path, method)
if key in seen:
continue
seen.add(key)
app.add_api_route(
path,
_endpoint_for(op),
methods=[method],
name=f"contract:{method}:{path}",
tags=cast(list[str | Enum], contract_openapi_tags(path)),
summary=op.notes or op.operation_id,
operation_id=_unique_operation_id(op, method, path),
openapi_extra={
"x-ovirt-operation-id": op.operation_id,
"x-ovirt-series": pack.series,
"x-ovirt-kind": op.kind,
},
)
registered += 1
app.openapi_schema = None
return registered
def _is_swappable_ovirt_route(route: object) -> bool:
name = getattr(route, "name", None)
return isinstance(name, str) and (
name.startswith("contract:") or name.startswith("ovirt-fallback:")
)
def _unique_operation_id(op: OperationSpec, method: str, path: str) -> str:
"""Build a unique OpenAPI operationId (packs repeat ids across /v3|/v4)."""
base = op.operation_id.replace(".", "_")
suffix = _NON_ALNUM.sub("_", f"{method}_{path}").strip("_")
return f"{base}__{suffix}"
def _normalize_route_path(path: str) -> str:
"""Ensure FastAPI path template form (leading slash, no trailing slash except root)."""
cleaned = path.strip() or "/"
if not cleaned.startswith("/"):
cleaned = f"/{cleaned}"
if cleaned != "/" and cleaned.endswith("/"):
cleaned = cleaned.rstrip("/")
return cleaned
def _endpoint_for(op: OperationSpec) -> Endpoint:
async def dispatch(request: Request) -> Response:
from app.ovirt.routes.engine import handle_engine_request
return await handle_engine_request(request)
dispatch.__name__ = f"contract_{op.operation_id.replace('.', '_')}"
dispatch.__doc__ = op.notes or op.operation_id
return dispatch
+353
View File
@@ -0,0 +1,353 @@
"""Map database rows to oVirt Engine API entities."""
from __future__ import annotations
import json
from typing import Any
def _data(row: Any) -> dict[str, Any]:
raw = row["data"] if "data" in row.keys() else {}
if isinstance(raw, str):
raw = json.loads(raw)
return dict(raw or {})
def href(collection: str, object_id: Any) -> str:
return f"/ovirt-engine/api/{collection}/{object_id}"
def link(rel: str, path: str) -> dict[str, str]:
return {"@rel": rel, "href": path} if False else {"rel": rel, "href": path}
def vm_entity(row: Any) -> dict[str, Any]:
vid = str(row["id"])
entity = {
"id": vid,
"href": href("vms", vid),
"name": row["name"],
"description": row["description"] or "",
"status": row["status"],
"type": row["type"],
"memory": int(row["memory"]),
"cpu": {
"topology": {
"sockets": int(row["cpu_sockets"]),
"cores": int(row["cpu_cores"]),
"threads": int(row["cpu_threads"]),
}
},
"os": {"type": row["os_type"]},
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
"link": [
{"rel": "diskattachments", "href": f"/ovirt-engine/api/vms/{vid}/diskattachments"},
{"rel": "nics", "href": f"/ovirt-engine/api/vms/{vid}/nics"},
{"rel": "snapshots", "href": f"/ovirt-engine/api/vms/{vid}/snapshots"},
{"rel": "tags", "href": f"/ovirt-engine/api/vms/{vid}/tags"},
{"rel": "permissions", "href": f"/ovirt-engine/api/vms/{vid}/permissions"},
{"rel": "cdroms", "href": f"/ovirt-engine/api/vms/{vid}/cdroms"},
{"rel": "graphicsconsoles", "href": f"/ovirt-engine/api/vms/{vid}/graphicsconsoles"},
],
}
if row["template_id"]:
entity["template"] = {
"id": str(row["template_id"]),
"href": href("templates", row["template_id"]),
}
if row["host_id"]:
entity["host"] = {"id": str(row["host_id"]), "href": href("hosts", row["host_id"])}
entity.update({k: v for k, v in _data(row).items() if k not in entity})
return entity
def disk_entity(row: Any) -> dict[str, Any]:
did = str(row["id"])
entity = {
"id": did,
"href": href("disks", did),
"name": row["name"],
"description": row["description"] or "",
"status": row["status"],
"provisioned_size": int(row["provisioned_size"]),
"actual_size": int(row["actual_size"]),
"format": row["format"],
"sparse": bool(row["sparse"]),
"shareable": bool(row["shareable"]),
"wipe_after_delete": bool(row["wipe_after_delete"]),
"link": [
{"rel": "permissions", "href": f"/ovirt-engine/api/disks/{did}/permissions"},
{"rel": "statistics", "href": f"/ovirt-engine/api/disks/{did}/statistics"},
],
}
if row["storage_domain_id"]:
entity["storage_domains"] = {
"storage_domain": [
{
"id": str(row["storage_domain_id"]),
"href": href("storagedomains", row["storage_domain_id"]),
}
]
}
entity.update({k: v for k, v in _data(row).items() if k not in entity})
return entity
def host_entity(row: Any) -> dict[str, Any]:
hid = str(row["id"])
return {
"id": hid,
"href": href("hosts", hid),
"name": row["name"],
"address": row["address"],
"status": row["status"],
"type": row["type"],
"memory": int(row["memory"]),
"cpu": {"topology": {"cores": int(row["cpu_cores"])}},
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
"link": [
{"rel": "nics", "href": f"/ovirt-engine/api/hosts/{hid}/nics"},
{"rel": "tags", "href": f"/ovirt-engine/api/hosts/{hid}/tags"},
{"rel": "permissions", "href": f"/ovirt-engine/api/hosts/{hid}/permissions"},
{"rel": "statistics", "href": f"/ovirt-engine/api/hosts/{hid}/statistics"},
],
**{k: v for k, v in _data(row).items()},
}
def datacenter_entity(row: Any) -> dict[str, Any]:
did = str(row["id"])
return {
"id": did,
"href": href("datacenters", did),
"name": row["name"],
"description": row["description"] or "",
"local": bool(row["local"]),
"status": row["status"],
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
"link": [
{"rel": "clusters", "href": f"/ovirt-engine/api/datacenters/{did}/clusters"},
{"rel": "storagedomains", "href": f"/ovirt-engine/api/datacenters/{did}/storagedomains"},
{"rel": "networks", "href": f"/ovirt-engine/api/datacenters/{did}/networks"},
{"rel": "quotas", "href": f"/ovirt-engine/api/datacenters/{did}/quotas"},
{"rel": "permissions", "href": f"/ovirt-engine/api/datacenters/{did}/permissions"},
],
**{k: v for k, v in _data(row).items()},
}
def cluster_entity(row: Any) -> dict[str, Any]:
cid = str(row["id"])
return {
"id": cid,
"href": href("clusters", cid),
"name": row["name"],
"description": row["description"] or "",
"cpu": {"type": row["cpu_type"]},
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
"data_center": {
"id": str(row["datacenter_id"]),
"href": href("datacenters", row["datacenter_id"]),
},
"link": [
{"rel": "networks", "href": f"/ovirt-engine/api/clusters/{cid}/networks"},
{"rel": "affinitygroups", "href": f"/ovirt-engine/api/clusters/{cid}/affinitygroups"},
{"rel": "permissions", "href": f"/ovirt-engine/api/clusters/{cid}/permissions"},
],
**{k: v for k, v in _data(row).items()},
}
def network_entity(row: Any) -> dict[str, Any]:
nid = str(row["id"])
entity: dict[str, Any] = {
"id": nid,
"href": href("networks", nid),
"name": row["name"],
"description": row["description"] or "",
"stp": bool(row["stp"]),
"data_center": {
"id": str(row["datacenter_id"]),
"href": href("datacenters", row["datacenter_id"]),
},
"link": [
{"rel": "vnicprofiles", "href": f"/ovirt-engine/api/networks/{nid}/vnicprofiles"},
{"rel": "permissions", "href": f"/ovirt-engine/api/networks/{nid}/permissions"},
],
}
if row["vlan_id"] is not None:
entity["vlan"] = {"id": int(row["vlan_id"])}
entity.update(_data(row))
return entity
def storage_domain_entity(row: Any) -> dict[str, Any]:
sid = str(row["id"])
return {
"id": sid,
"href": href("storagedomains", sid),
"name": row["name"],
"type": row["type"],
"storage": {"type": row["storage_type"]},
"status": row["status"],
"available": int(row["available"]),
"used": int(row["used"]),
"committed": int(row["committed"]),
"link": [
{"rel": "disks", "href": f"/ovirt-engine/api/storagedomains/{sid}/disks"},
{"rel": "files", "href": f"/ovirt-engine/api/storagedomains/{sid}/files"},
{"rel": "permissions", "href": f"/ovirt-engine/api/storagedomains/{sid}/permissions"},
],
**{k: v for k, v in _data(row).items()},
}
def template_entity(row: Any) -> dict[str, Any]:
tid = str(row["id"])
entity: dict[str, Any] = {
"id": tid,
"href": href("templates", tid),
"name": row["name"],
"description": row["description"] or "",
"status": row["status"],
"memory": int(row["memory"]),
"cpu": {
"topology": {"sockets": int(row["cpu_sockets"]), "cores": int(row["cpu_cores"])}
},
"link": [
{"rel": "diskattachments", "href": f"/ovirt-engine/api/templates/{tid}/diskattachments"},
{"rel": "nics", "href": f"/ovirt-engine/api/templates/{tid}/nics"},
],
}
if row["cluster_id"]:
entity["cluster"] = {
"id": str(row["cluster_id"]),
"href": href("clusters", row["cluster_id"]),
}
entity.update(_data(row))
return entity
def user_entity(row: Any) -> dict[str, Any]:
uid = str(row["id"])
return {
"id": uid,
"href": href("users", uid),
"name": row["name"],
"user_name": f"{row['name']}@{row['domain_name']}",
"domain": {"id": str(row["domain_id"]), "name": row["domain_name"]},
"link": [
{"rel": "roles", "href": f"/ovirt-engine/api/users/{uid}/roles"},
{"rel": "permissions", "href": f"/ovirt-engine/api/users/{uid}/permissions"},
{"rel": "tags", "href": f"/ovirt-engine/api/users/{uid}/tags"},
],
}
def generic_entity(collection: str, element: str, row: Any) -> dict[str, Any]:
if row is None:
from app.ovirt.errors import OVirtError
raise OVirtError("NotFound", f"{element} not found", status_code=404)
oid = str(row["id"])
data = _data(row)
entity = {
"id": oid,
"href": href(collection, oid),
"name": row["name"] or data.get("name") or element,
"status": row["status"],
}
entity.update(data)
return entity
def job_entity(row: Any) -> dict[str, Any]:
jid = str(row["id"])
entity: dict[str, Any] = {
"id": jid,
"href": href("jobs", jid),
"description": row["description"] or "",
"status": row["status"],
}
if row["started"] is not None:
entity["started"] = row["started"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
if row["ended"] is not None:
entity["ended"] = row["ended"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
entity.update({k: v for k, v in _data(row).items() if k not in entity and k != "action_status"})
return entity
def action_entity(job_row: Any) -> dict[str, Any]:
"""Build an action response from a persisted job row (status from job.data)."""
data = _data(job_row)
status = data.get("action_status")
if status is None:
status = job_row["status"]
return {"status": str(status), "job": job_entity(job_row)}
def disk_attachment_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
vid = vm_id or str(row["vm_id"])
aid = str(row["id"])
entity: dict[str, Any] = {
"id": aid,
"href": f"/ovirt-engine/api/vms/{vid}/diskattachments/{aid}",
"active": bool(row["active"]),
"bootable": bool(row["bootable"]),
"interface": row["interface"],
"disk": {"id": str(row["disk_id"]), "href": href("disks", row["disk_id"])},
"vm": {"id": vid, "href": href("vms", vid)},
}
if "disk_name" in row.keys() and row["disk_name"] is not None:
entity["disk"]["name"] = row["disk_name"]
entity.update({k: v for k, v in _data(row).items() if k not in entity})
return entity
def nic_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
vid = vm_id or str(row["vm_id"])
nid = str(row["id"])
entity: dict[str, Any] = {
"id": nid,
"href": f"/ovirt-engine/api/vms/{vid}/nics/{nid}",
"name": row["name"],
"interface": row["interface"],
"linked": bool(row["linked"]),
"plugged": bool(row["plugged"]),
"mac": {"address": row["mac_address"] or ""},
}
if row["vnic_profile_id"]:
entity["vnic_profile"] = {
"id": str(row["vnic_profile_id"]),
"href": href("vnicprofiles", row["vnic_profile_id"]),
}
entity.update({k: v for k, v in _data(row).items() if k not in entity})
return entity
def snapshot_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
vid = vm_id or str(row["vm_id"])
sid = str(row["id"])
entity: dict[str, Any] = {
"id": sid,
"href": f"/ovirt-engine/api/vms/{vid}/snapshots/{sid}",
"description": row["description"] or "",
"status": row["status"],
"snapshot_type": row["snapshot_type"],
"persist_memorystate": bool(row["persist_memorystate"]),
}
if row["created_at"] is not None:
entity["date"] = row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
entity.update({k: v for k, v in _data(row).items() if k not in entity})
return entity
def tag_entity(row: Any) -> dict[str, Any]:
tid = str(row["id"])
return {
"id": tid,
"href": href("tags", tid),
"name": row["name"],
"description": row["description"] or "",
}
+1
View File
@@ -0,0 +1 @@
"""Specialized oVirt Engine route modules."""
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
"""oVirt Engine SSO OAuth2 endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from fastapi.responses import JSONResponse
from app.ovirt.auth import issue_oauth_token
from app.ovirt.deps import get_db
from app.ovirt.errors import OVirtError
from app.ovirt.settings import OPT_DEFAULT_API_SCOPE, option_value
router = APIRouter(tags=["sso"])
@router.post("/ovirt-engine/sso/oauth/token")
async def oauth_token(
request: Request,
grant_type: str = Form(default=""),
username: str = Form(default=""),
password: str = Form(default=""),
scope: str = Form(default=""),
) -> JSONResponse:
if grant_type and grant_type != "password":
raise OVirtError("BadRequest", f"Unsupported grant_type: {grant_type}", status_code=400)
if not username:
try:
body = await request.json()
except Exception:
body = {}
username = str(body.get("username") or "")
password = str(body.get("password") or "")
scope = str(body.get("scope") or scope or "")
grant_type = str(body.get("grant_type") or grant_type or "password")
if not username or not password:
raise OVirtError("Unauthorized", "username and password required", status_code=401)
db = get_db(request)
async with db.pool.acquire() as conn:
if not scope:
scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
token = await issue_oauth_token(
conn, username=username, password=password, scope=scope
)
return JSONResponse(token)
@router.get("/ovirt-engine/sso/oauth/token-info")
async def token_info(request: Request) -> JSONResponse:
from app.ovirt.auth import resolve_request_auth
db = get_db(request)
async with db.pool.acquire() as conn:
ctx = await resolve_request_auth(conn, dict(request.headers))
row = await conn.fetchrow(
"SELECT scope, revoked, expires_at FROM ov_tokens WHERE id=$1", ctx.token_id
)
active = bool(row) and not row["revoked"]
return JSONResponse(
{
"active": active,
"user_id": str(ctx.user_id),
"user_name": f"{ctx.user_name}@{ctx.domain}",
"exp": int(ctx.expires_at.timestamp()),
"scope": (row["scope"] if row else ctx.scope),
}
)
@router.post("/ovirt-engine/sso/oauth/revoke")
async def revoke_token(request: Request) -> JSONResponse:
from app.ovirt.auth import extract_auth
db = get_db(request)
kind = extract_auth(dict(request.headers))
token = None
if kind and kind[0] in {"bearer", "session"}:
token = kind[1]
else:
form = await request.form()
token = str(form.get("token") or "")
result = "missing"
if token:
async with db.pool.acquire() as conn:
updated = await conn.fetchval(
"""UPDATE ov_tokens SET revoked=true WHERE id=$1
RETURNING id""",
token,
)
result = "ok" if updated else "not_found"
return JSONResponse({"result": result})
+330
View File
@@ -0,0 +1,330 @@
"""Generic surface-complete CRUD backed by ov_api_objects for undeclared collections."""
from __future__ import annotations
import json
from typing import Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import Request, Response
from app.ovirt.errors import OVirtError
from app.ovirt.repr import generic_entity
from app.ovirt.serialize import respond, unwrap_entity
# Map collection path segment → (element singular, default status)
_COLLECTIONS: dict[str, tuple[str, str]] = {
"affinitylabels": ("affinity_label", "ok"),
"bookmarks": ("bookmark", "ok"),
"clusterlevels": ("cluster_level", "ok"),
"domains": ("domain", "ok"),
"externalhostproviders": ("external_host_provider", "ok"),
"groups": ("group", "ok"),
"icons": ("icon", "ok"),
"instancetypes": ("instance_type", "ok"),
"katelloerrata": ("katello_erratum", "ok"),
"macpools": ("mac_pool", "ok"),
"networkfilters": ("network_filter", "ok"),
"openstackimageproviders": ("openstack_image_provider", "ok"),
"openstacknetworkproviders": ("openstack_network_provider", "ok"),
"openstackvolumeproviders": ("openstack_volume_provider", "ok"),
"operatingsystems": ("operating_system", "ok"),
"permissions": ("permission", "ok"),
"roles": ("role", "ok"),
"schedulingpolicies": ("scheduling_policy", "ok"),
"schedulingpolicyunits": ("scheduling_policy_unit", "ok"),
"tags": ("tag", "ok"),
"vmpools": ("vm_pool", "ok"),
"imagetransfers": ("image_transfer", "ok"),
"options": ("engine_option", "ok"),
"networklabels": ("network_label", "ok"),
"cpuprofiles": ("cpu_profile", "ok"),
"diskprofiles": ("disk_profile", "ok"),
"qoss": ("qos", "ok"),
"iscsibonds": ("iscsi_bond", "ok"),
"glustervolumes": ("gluster_volume", "ok"),
"files": ("file", "ok"),
"images": ("image", "ok"),
"permits": ("permit", "ok"),
"filters": ("filter", "ok"),
"weights": ("weight", "ok"),
"balances": ("balance", "ok"),
"enabledfeatures": ("cluster_enabled_feature", "ok"),
"cdroms": ("cdrom", "ok"),
"graphicsconsoles": ("graphics_console", "ok"),
"reporteddevices": ("reported_device", "ok"),
"sessions": ("session", "ok"),
"applications": ("application", "ok"),
"watchdogs": ("watchdog", "ok"),
"hostdevices": ("host_device", "ok"),
"numanodes": ("numa_node", "ok"),
"mediateddevices": ("vm_mediated_device", "ok"),
"statistics": ("statistic", "ok"),
"hooks": ("hook", "ok"),
"devices": ("host_device", "ok"),
"sshpublickeys": ("ssh_public_key", "ok"),
"networkfilterparameters": ("network_filter_parameter", "ok"),
}
def _meta(collection: str) -> tuple[str, str]:
return _COLLECTIONS.get(collection, (collection.rstrip("s") or "object", "ok"))
async def handle_generic(
request: Request,
conn: Connection,
method: str,
parts: list[str],
payload: dict[str, Any],
) -> Response:
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
collection = parts[0]
element, _catalog_status = _meta(collection)
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
if len(parts) == 1:
if method == "GET":
rows = await conn.fetch(
"SELECT * FROM ov_api_objects WHERE collection=$1 ORDER BY name", collection
)
items = [generic_entity(collection, element, r) for r in rows]
return respond(request, element=element, collection=collection, data=items)
if method == "POST":
body = unwrap_entity(payload, element)
oid = uuid4()
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
VALUES($1,$2,$3,$4,$5::jsonb)""",
oid,
collection,
name,
str(body.get("status") or default_status),
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
return respond(
request, element=element, data=generic_entity(collection, element, row), status_code=201
)
if len(parts) == 2:
oid = parts[1]
row = await conn.fetchrow(
"SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
)
if method == "GET":
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
return respond(request, element=element, data=generic_entity(collection, element, row))
if method == "PUT":
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
body = unwrap_entity(payload, element)
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
data.update(body)
await conn.execute(
"""UPDATE ov_api_objects SET name=COALESCE($2,name), data=$3::jsonb, updated_at=now()
WHERE id=$1::uuid""",
oid,
body.get("name"),
json.dumps(data),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
return respond(request, element=element, data=generic_entity(collection, element, row))
if method == "DELETE":
await conn.execute(
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
)
return Response(status_code=200)
if len(parts) == 3 and method == "POST":
from app.ovirt.jobs import respond_action
return await respond_action(
request, conn, description=f"{collection} {parts[2]}"
)
if len(parts) >= 3:
return await handle_subcollection(
request, conn, method, collection, parts[1], parts[2], parts[3:], payload
)
raise OVirtError("NotFound", f"No handler for /{'/'.join(parts)}", status_code=404)
_PARENT_OBJECT_TYPE: dict[str, str] = {
"vms": "vm",
"hosts": "host",
"disks": "disk",
"datacenters": "data_center",
"clusters": "cluster",
"networks": "network",
"storagedomains": "storage_domain",
"templates": "template",
"users": "user",
"groups": "group",
"vnicprofiles": "vnic_profile",
"vmpools": "vm_pool",
}
async def handle_subcollection(
request: Request,
conn: Connection,
method: str,
parent_collection: str,
parent_id: str,
sub: str,
rest: list[str],
payload: dict[str, Any],
) -> Response:
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
element, _catalog_status = _meta(sub)
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
collection_key = sub
if not rest and method == "GET" and sub == "permissions":
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
rows = await conn.fetch(
"""SELECT p.*, r.name AS role_name, u.name AS user_name
FROM ov_permissions p
JOIN ov_roles r ON r.id = p.role_id
LEFT JOIN ov_users u ON u.id = p.user_id
WHERE p.object_type=$1 AND p.object_id=$2::uuid
ORDER BY r.name""",
object_type,
parent_id,
)
items = [
{
"id": str(r["id"]),
"href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}",
"role": {"id": str(r["role_id"]), "name": r["role_name"]},
"object": {"type": r["object_type"], "id": str(parent_id)},
**(
{"user": {"id": str(r["user_id"]), "name": r["user_name"]}}
if r["user_id"] is not None
else {}
),
}
for r in rows
]
return respond(request, element="permission", collection="permissions", data=items)
if not rest and method == "GET" and sub == "tags":
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
rows = await conn.fetch(
"""SELECT t.*
FROM ov_tag_assignments a
JOIN ov_tags t ON t.id = a.tag_id
WHERE a.object_type=$1 AND a.object_id=$2::uuid
ORDER BY t.name""",
object_type,
parent_id,
)
items = [
{
"id": str(r["id"]),
"href": f"/ovirt-engine/api/tags/{r['id']}",
"name": r["name"],
"description": r["description"] or "",
}
for r in rows
]
return respond(request, element="tag", collection="tags", data=items)
if not rest:
if method == "GET":
rows = await conn.fetch(
"""SELECT * FROM ov_api_objects
WHERE collection=$1 AND parent_collection=$2 AND parent_id=$3::uuid
ORDER BY name""",
collection_key,
parent_collection,
parent_id,
)
items = [
generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, r) for r in rows
]
# Fix hrefs
for item, row in zip(items, rows, strict=False):
item["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{row['id']}"
return respond(request, element=element, collection=sub, data=items)
if method == "POST":
body = unwrap_entity(payload, element)
oid = uuid4()
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, parent_collection, parent_id, data)
VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)""",
oid,
collection_key,
name,
str(body.get("status") or default_status),
parent_collection,
parent_id,
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
data = generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, row)
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
return respond(request, element=element, data=data, status_code=201)
if len(rest) == 1:
oid = rest[0]
if method == "GET":
row = await conn.fetchrow(
"""SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2
AND parent_id=$3::uuid""",
oid,
collection_key,
parent_id,
)
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
data = generic_entity(sub, element, row)
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
return respond(request, element=element, data=data)
if method == "DELETE":
await conn.execute(
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND parent_id=$2::uuid",
oid,
parent_id,
)
return Response(status_code=200)
if method == "PUT":
body = unwrap_entity(payload, element)
await conn.execute(
"UPDATE ov_api_objects SET data=$2::jsonb, updated_at=now() WHERE id=$1::uuid",
oid,
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
return respond(request, element=element, data=generic_entity(sub, element, row))
if len(rest) == 2 and method == "POST":
from app.ovirt.jobs import respond_action
return await respond_action(
request, conn, description=f"{parent_collection}/{sub} {rest[1]}"
)
raise OVirtError(
"NotFound",
f"No handler for /{parent_collection}/{parent_id}/{sub}/{'/'.join(rest)}",
status_code=404,
)
def remount_schema_services(app: Any, series: str) -> int:
"""Hot-swap contract pack: reload ops and re-register OpenAPI routes."""
from app.ovirt.contract_loader import ensure_loaded
from app.ovirt.registry import clear_ovirt_contract_routes, register_ovirt_contract_routes
from app.ovirt.routes import engine
rt = ensure_loaded(series)
summary = rt.reload(series)
clear_ovirt_contract_routes(app)
registered = 0
if rt.pack is not None:
registered = register_ovirt_contract_routes(app, rt.pack)
# Re-attach catch-all after specific contract routes (match order matters).
app.include_router(engine.router)
app.state.ovirt_series = series
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
app.state.runtime_version = f"ovirt-{series}"
return int(app.state.ovirt_schema_ops)
+348
View File
@@ -0,0 +1,348 @@
"""Minimal and demo seed profiles for the oVirt Engine simulator."""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.ovirt.ids import stable_id
from app.security.auth import hash_secret
MINIMAL_PROFILE = "minimal"
DEMO_PROFILE = "demo"
async def clear_ovirt_state(conn: Connection) -> None:
tables = [
"ov_job_steps",
"ov_jobs",
"ov_events",
"ov_tag_assignments",
"ov_tags",
"ov_snapshots",
"ov_nics",
"ov_disk_attachments",
"ov_disks",
"ov_vms",
"ov_templates",
"ov_affinity_groups",
"ov_quotas",
"ov_vnic_profiles",
"ov_networks",
"ov_storage_domain_attachments",
"ov_storage_domains",
"ov_storage_connections",
"ov_hosts",
"ov_clusters",
"ov_datacenters",
"ov_bookmarks",
"ov_permissions",
"ov_tokens",
"ov_users",
"ov_groups",
"ov_roles",
"ov_domains",
"ov_api_objects",
"ov_demo_meta",
]
for table in tables:
await conn.execute(f"TRUNCATE TABLE {table} CASCADE")
async def seed_ovirt(conn: Connection) -> dict[str, Any]:
"""Minimal lab: 1 DC, 1 cluster, 1 host, Blank template, admin user."""
await clear_ovirt_state(conn)
domain_id = stable_id("domain", "internal")
await conn.execute(
"INSERT INTO ov_domains(id, name) VALUES($1, 'internal')", domain_id
)
roles = [
(stable_id("role", "SuperUser"), "SuperUser", True),
(stable_id("role", "UserRole"), "UserRole", False),
(stable_id("role", "ClusterAdmin"), "ClusterAdmin", True),
]
for rid, name, admin in roles:
await conn.execute(
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
rid,
name,
admin,
)
admin_id = stable_id("user", "admin")
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
await conn.execute(
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
VALUES($1,$2,'admin',$3,true,'admin@internal')""",
admin_id,
domain_id,
pwd,
)
await conn.execute(
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
VALUES($1,$2,$3,'system')""",
stable_id("perm", "admin-super"),
roles[0][0],
admin_id,
)
# Extra lab users
for uname in ("ops", "developer", "demo"):
uid = stable_id("user", uname)
await conn.execute(
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
VALUES($1,$2,$3,$4,true,$5)""",
uid,
domain_id,
uname,
pwd,
f"{uname}@internal",
)
await conn.execute(
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
VALUES($1,$2,$3,'system')""",
stable_id("perm", uname),
roles[1][0],
uid,
)
dc_id = stable_id("dc", "Default")
await conn.execute(
"""INSERT INTO ov_datacenters(id, name, description, status, version_major, version_minor)
VALUES($1,'Default','Default datacenter','up',4,5)""",
dc_id,
)
cluster_id = stable_id("cluster", "Default")
await conn.execute(
"""INSERT INTO ov_clusters(id, datacenter_id, name, description)
VALUES($1,$2,'Default','Default cluster')""",
cluster_id,
dc_id,
)
host_id = stable_id("host", "host01")
await conn.execute(
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores)
VALUES($1,$2,'host01','192.168.1.10','up',$3,16)""",
host_id,
cluster_id,
128 * 1024**3,
)
net_id = stable_id("net", "ovirtmgmt")
await conn.execute(
"""INSERT INTO ov_networks(id, datacenter_id, name, description)
VALUES($1,$2,'ovirtmgmt','Management network')""",
net_id,
dc_id,
)
profile_id = stable_id("vnic", "ovirtmgmt")
await conn.execute(
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,'ovirtmgmt')",
profile_id,
net_id,
)
sd_id = stable_id("sd", "data1")
await conn.execute(
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
VALUES($1,'data1','data','nfs','active',$2,$3)""",
sd_id,
2 * 1024**4,
100 * 1024**3,
)
await conn.execute(
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
VALUES($1,$2,$3,'active')""",
stable_id("sda", "data1"),
sd_id,
dc_id,
)
await conn.execute(
"""INSERT INTO ov_storage_connections(id, type, address, path)
VALUES($1,'nfs','nfs.lab.local','/export/data1')""",
stable_id("sc", "data1"),
)
blank_id = stable_id("template", "Blank")
await conn.execute(
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory)
VALUES($1,$2,'Blank','Blank template','ok',$3)""",
blank_id,
cluster_id,
1024**3,
)
# One sample VM
vm_id = stable_id("vm", "lab-vm-01")
await conn.execute(
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type)
VALUES($1,$2,$3,'lab-vm-01','Sample VM','down',$4,1,2,1,'rhel_8x64','server')""",
vm_id,
cluster_id,
blank_id,
2 * 1024**3,
)
disk_id = stable_id("disk", "lab-vm-01")
await conn.execute(
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
VALUES($1,'lab-vm-01_Disk1',$2,$2,$3)""",
disk_id,
20 * 1024**3,
sd_id,
)
await conn.execute(
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
VALUES($1,$2,$3,true)""",
stable_id("da", "lab-vm-01"),
vm_id,
disk_id,
)
await conn.execute(
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
VALUES($1,$2,'nic1','00:1a:4a:16:01:01',$3)""",
stable_id("nic", "lab-vm-01"),
vm_id,
profile_id,
)
await conn.execute(
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'AllVMs','Vms:')",
stable_id("bm", "AllVMs"),
)
await conn.execute(
"""INSERT INTO ov_events(code, severity, description, user_id, vm_id)
VALUES(1,'normal','Engine started',$1,$2)""",
admin_id,
vm_id,
)
await conn.execute(
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
stable_id("group", "engine-admins"),
domain_id,
)
await conn.execute(
"INSERT INTO ov_tags(id, name, description) VALUES($1,'lab','Lab tag')",
stable_id("tag", "lab"),
)
job_id = stable_id("job", "seed-1")
await conn.execute(
"""INSERT INTO ov_jobs(id, description, status, owner_id)
VALUES($1,'Seed inventory job','finished',$2)""",
job_id,
admin_id,
)
await conn.execute(
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
VALUES($1,$2,'Finish seed','finished','executing',1)""",
stable_id("step", "seed-1"),
job_id,
)
# Surface-complete sample objects (generic collections)
for collection, name in (
("instancetypes", "Large"),
("macpools", "Default"),
("schedulingpolicies", "evenly_distributed"),
("schedulingpolicyunits", "EvenlyDistributed"),
("clusterlevels", "4.5"),
("icons", "default"),
("operatingsystems", "rhel_8x64"),
("networkfilters", "vdsm-no-mac-spoofing"),
("vmpools", "pool-demo"),
("affinitylabels", "label-a"),
("katelloerrata", "RHSA-2024:0001"),
("externalhostproviders", "foreman-lab"),
("openstacknetworkproviders", "ovn-provider"),
("openstackimageproviders", "glance-lab"),
("openstackvolumeproviders", "cinder-lab"),
("imagetransfers", "transfer-1"),
):
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
VALUES($1,$2,$3,'ok',$4::jsonb)""",
stable_id("obj", collection, name),
collection,
name,
json.dumps({"name": name, "description": f"seed {collection}"}),
)
from app.ovirt.settings import seed_engine_options
await seed_engine_options(conn)
# Nested entity links + resource permissions (avoid empty subcollection GETs)
from app.ovirt.seed_nested import seed_nested_for_inventory
tag_id = stable_id("tag", "lab")
await conn.execute(
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
stable_id("ta", "lab", "lab-vm-01"),
tag_id,
vm_id,
)
await conn.execute(
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
VALUES($1,$2,'seed-snapshot','ok')""",
stable_id("snap", "lab-vm-01", "1"),
vm_id,
)
await conn.execute(
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
VALUES($1,$2,'Default','Default quota')""",
stable_id("quota", "Default"),
dc_id,
)
await conn.execute(
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
VALUES($1,$2,'web-affinity',true,true)""",
stable_id("ag", "Default"),
cluster_id,
)
user_ids = [
r["id"]
for r in await conn.fetch("SELECT id FROM ov_users ORDER BY name")
]
group_ids = [r["id"] for r in await conn.fetch("SELECT id FROM ov_groups ORDER BY name")]
await seed_nested_for_inventory(
conn,
admin_user_id=admin_id,
role_user_id=roles[1][0],
datacenter_ids=[dc_id],
cluster_ids=[cluster_id],
host_ids=[host_id],
network_ids=[net_id],
storage_domain_ids=[sd_id],
template_ids=[blank_id],
vm_ids=[vm_id],
disk_ids=[disk_id],
tag_ids=[tag_id],
user_ids=user_ids,
group_ids=group_ids,
)
await conn.execute(
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", MINIMAL_PROFILE
)
return {
"profile": MINIMAL_PROFILE,
"vms": 1,
"hosts": 1,
"datacenters": 1,
"users": 4,
}
async def ovirt_demo_summary(conn: Connection) -> dict[str, Any]:
profile = await conn.fetchval("SELECT value FROM ov_demo_meta WHERE key='profile'")
return {
"profile": profile or MINIMAL_PROFILE,
"loaded": profile == DEMO_PROFILE,
"vms": await conn.fetchval("SELECT count(*) FROM ov_vms") or 0,
"hosts": await conn.fetchval("SELECT count(*) FROM ov_hosts") or 0,
"datacenters": await conn.fetchval("SELECT count(*) FROM ov_datacenters") or 0,
"clusters": await conn.fetchval("SELECT count(*) FROM ov_clusters") or 0,
"disks": await conn.fetchval("SELECT count(*) FROM ov_disks") or 0,
"networks": await conn.fetchval("SELECT count(*) FROM ov_networks") or 0,
"storage_domains": await conn.fetchval("SELECT count(*) FROM ov_storage_domains") or 0,
"templates": await conn.fetchval("SELECT count(*) FROM ov_templates") or 0,
"users": await conn.fetchval("SELECT count(*) FROM ov_users") or 0,
"events": await conn.fetchval("SELECT count(*) FROM ov_events") or 0,
"jobs": await conn.fetchval("SELECT count(*) FROM ov_jobs") or 0,
}
+41
View File
@@ -0,0 +1,41 @@
"""CLI: python -m app.ovirt.seed_cli [--profile minimal|demo]."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import asyncpg
from app.ovirt.demo_datacenter import seed_ovirt_demo
from app.ovirt.seed import seed_ovirt
async def _run(profile: str) -> dict:
dsn = os.environ.get(
"DATABASE_URL",
"postgresql://ovirt:ovirt@localhost:5432/ovirt_simulator",
)
conn = await asyncpg.connect(dsn)
try:
if profile == "demo":
result = await seed_ovirt_demo(conn)
else:
result = await seed_ovirt(conn)
return result
finally:
await conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Seed oVirt Engine simulator")
parser.add_argument("--profile", default=os.environ.get("SEED_PROFILE", "minimal"))
args = parser.parse_args()
result = asyncio.run(_run(args.profile))
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+432
View File
@@ -0,0 +1,432 @@
"""Seed nested Engine subcollections so entity `link` hrefs are non-empty.
Rows go into `ov_api_objects` (with parent_collection/parent_id) and into
`ov_permissions` / `ov_tag_assignments` where specialized tables exist.
"""
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
from asyncpg import Connection
from app.ovirt.ids import stable_id
def _obj_row(
*,
parent_collection: str,
parent_id: UUID,
collection: str,
name: str,
data: dict[str, Any] | None = None,
status: str = "ok",
) -> tuple[Any, ...]:
payload = {"name": name, **(data or {})}
return (
stable_id("nested", parent_collection, str(parent_id), collection, name),
collection,
name,
status,
parent_collection,
parent_id,
json.dumps(payload),
)
async def _insert_objs(conn: Connection, rows: list[tuple[Any, ...]]) -> None:
if not rows:
return
await conn.executemany(
"""INSERT INTO ov_api_objects(
id, collection, name, status, parent_collection, parent_id, data
) VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)
ON CONFLICT (id) DO NOTHING""",
rows,
)
async def seed_nested_for_inventory(
conn: Connection,
*,
admin_user_id: UUID,
role_user_id: UUID,
datacenter_ids: list[UUID],
cluster_ids: list[UUID],
host_ids: list[UUID],
network_ids: list[UUID],
storage_domain_ids: list[UUID],
template_ids: list[UUID],
vm_ids: list[UUID],
disk_ids: list[UUID],
tag_ids: list[UUID] | None = None,
user_ids: list[UUID] | None = None,
group_ids: list[UUID] | None = None,
) -> None:
"""Populate nested surface for the given inventory sample."""
obj_rows: list[tuple[Any, ...]] = []
perm_rows: list[tuple[Any, ...]] = []
tag_rows: list[tuple[Any, ...]] = []
if user_ids is None:
user_ids = []
if group_ids is None:
group_ids = []
def perm(object_type: str, object_id: UUID, key: str) -> None:
perm_rows.append(
(
stable_id("perm", object_type, key),
role_user_id,
admin_user_id,
object_type,
object_id,
)
)
for dc_id in datacenter_ids:
perm("data_center", dc_id, f"dc-{dc_id}")
obj_rows.append(
_obj_row(
parent_collection="datacenters",
parent_id=dc_id,
collection="qoss",
name="default-qos",
data={"type": "storage"},
)
)
obj_rows.append(
_obj_row(
parent_collection="datacenters",
parent_id=dc_id,
collection="iscsibonds",
name="iscsi-bond-1",
data={"description": "iSCSI bond"},
)
)
for cluster_id in cluster_ids:
perm("cluster", cluster_id, f"cluster-{cluster_id}")
obj_rows.append(
_obj_row(
parent_collection="clusters",
parent_id=cluster_id,
collection="cpuprofiles",
name="Default",
data={"description": "Default CPU profile"},
)
)
obj_rows.append(
_obj_row(
parent_collection="clusters",
parent_id=cluster_id,
collection="enabledfeatures",
name="gluster",
data={"description": "Gluster feature"},
)
)
for host_id in host_ids:
perm("host", host_id, f"host-{host_id}")
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="nics",
name="eth0",
data={"mac": {"address": "00:1a:4a:00:00:01"}, "boot_protocol": "dhcp"},
)
)
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="nics",
name="eth1",
data={"mac": {"address": "00:1a:4a:00:00:02"}, "boot_protocol": "none"},
)
)
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="statistics",
name="memory.used",
data={"unit": "bytes", "values": {"value": [{"datum": 8 * 1024**3}]}},
)
)
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="statistics",
name="cpu.current.user",
data={"unit": "percent", "values": {"value": [{"datum": 12.5}]}},
)
)
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="devices",
name="pci-0000:00:1f.2",
data={"capability": "storage", "vendor": "lab"},
)
)
obj_rows.append(
_obj_row(
parent_collection="hosts",
parent_id=host_id,
collection="hooks",
name="before_vm_start",
data={"event_name": "before_vm_start"},
)
)
if tag_ids:
tag_rows.append(
(
stable_id("ta", "host", str(host_id), str(tag_ids[0])),
tag_ids[0],
"host",
host_id,
)
)
for net_id in network_ids:
perm("network", net_id, f"net-{net_id}")
for sd_id in storage_domain_ids:
perm("storage_domain", sd_id, f"sd-{sd_id}")
obj_rows.append(
_obj_row(
parent_collection="storagedomains",
parent_id=sd_id,
collection="files",
name="rhel-9.iso",
data={"type": "iso", "size": 8 * 1024**3},
)
)
obj_rows.append(
_obj_row(
parent_collection="storagedomains",
parent_id=sd_id,
collection="files",
name="virtio-win.iso",
data={"type": "iso", "size": 512 * 1024**2},
)
)
obj_rows.append(
_obj_row(
parent_collection="storagedomains",
parent_id=sd_id,
collection="images",
name="base-image",
data={"description": "Base disk image"},
)
)
obj_rows.append(
_obj_row(
parent_collection="storagedomains",
parent_id=sd_id,
collection="diskprofiles",
name="default",
data={"description": "Default disk profile"},
)
)
for tpl_id in template_ids:
perm("template", tpl_id, f"tpl-{tpl_id}")
obj_rows.append(
_obj_row(
parent_collection="templates",
parent_id=tpl_id,
collection="nics",
name="nic1",
data={"interface": "virtio"},
)
)
obj_rows.append(
_obj_row(
parent_collection="templates",
parent_id=tpl_id,
collection="diskattachments",
name="disk1",
data={"bootable": True, "interface": "virtio_scsi"},
)
)
obj_rows.append(
_obj_row(
parent_collection="templates",
parent_id=tpl_id,
collection="cdroms",
name="ide0",
data={"file": {"id": "rhel-9.iso"}},
)
)
for disk_id in disk_ids:
perm("disk", disk_id, f"disk-{disk_id}")
obj_rows.append(
_obj_row(
parent_collection="disks",
parent_id=disk_id,
collection="statistics",
name="data.current.read",
data={"unit": "bytespers", "values": {"value": [{"datum": 1024}]}},
)
)
if tag_ids:
for user_id in user_ids:
tag_rows.append(
(
stable_id("ta", "user", str(user_id), str(tag_ids[0])),
tag_ids[0],
"user",
user_id,
)
)
for group_id in group_ids:
perm("group", group_id, f"group-{group_id}")
tag_rows.append(
(
stable_id("ta", "group", str(group_id), str(tag_ids[0])),
tag_ids[0],
"group",
group_id,
)
)
else:
for group_id in group_ids:
perm("group", group_id, f"group-{group_id}")
for vm_id in vm_ids:
perm("vm", vm_id, f"vm-{vm_id}")
obj_rows.extend(
[
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="cdroms",
name="ide0",
data={"file": {"id": "rhel-9.iso"}},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="graphicsconsoles",
name="vnc",
data={"protocol": "vnc", "port": 5900, "address": "127.0.0.1"},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="graphicsconsoles",
name="spice",
data={"protocol": "spice", "port": 5901},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="reporteddevices",
name="eth0",
data={"type": "network", "mac": {"address": "00:1a:4a:01:00:01"}},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="sessions",
name="console-1",
data={"user": {"name": "admin@internal"}, "ip": {"address": "10.0.0.1"}},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="applications",
name="qemu-guest-agent",
data={"version": "8.2.0"},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="watchdogs",
name="i6300esb",
data={"model": "i6300esb", "action": "reset"},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="statistics",
name="memory.installed",
data={"unit": "bytes", "values": {"value": [{"datum": 2 * 1024**3}]}},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="numanodes",
name="0",
data={"index": 0, "memory": 1024**3},
),
_obj_row(
parent_collection="vms",
parent_id=vm_id,
collection="hostdevices",
name="pci_0000_00_02_0",
data={"capability": "pci"},
),
]
)
# Scheduling policy children + role permits
for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"):
sp_id = await conn.fetchval(
"SELECT id FROM ov_api_objects WHERE collection='schedulingpolicies' AND name=$1",
sp_name,
)
if sp_id is None:
continue
for sub, child in (
("filters", "Memory"),
("weights", "EvenlyDistributed"),
("balances", "EvenlyDistributed"),
):
obj_rows.append(
_obj_row(
parent_collection="schedulingpolicies",
parent_id=sp_id,
collection=sub,
name=child,
data={"factor": 1},
)
)
for role_name in ("SuperUser", "UserRole", "ClusterAdmin"):
role_id = await conn.fetchval("SELECT id FROM ov_roles WHERE name=$1", role_name)
if role_id is None:
continue
for permit in ("create_vm", "login", "manipulate_vm"):
obj_rows.append(
_obj_row(
parent_collection="roles",
parent_id=role_id,
collection="permits",
name=permit,
data={"administrative": role_name != "UserRole"},
)
)
if perm_rows:
await conn.executemany(
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type, object_id)
VALUES($1,$2,$3,$4,$5::uuid)
ON CONFLICT (id) DO NOTHING""",
perm_rows,
)
await _insert_objs(conn, obj_rows)
if tag_rows:
await conn.executemany(
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
VALUES($1,$2,$3,$4::uuid) ON CONFLICT DO NOTHING""",
tag_rows,
)
+173
View File
@@ -0,0 +1,173 @@
"""XML and JSON representation helpers for oVirt Engine API."""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Any
from xml.etree.ElementTree import Element, SubElement, tostring
from fastapi import Request
from fastapi.responses import JSONResponse, Response
_XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
def wants_xml(request: Request) -> bool:
accept = (request.headers.get("accept") or "").lower()
if "application/json" in accept or "+json" in accept:
return False
if "application/xml" in accept or "text/xml" in accept or "+xml" in accept:
return True
# Engine historically defaults to XML
return True
def parse_body(raw: bytes, content_type: str | None) -> dict[str, Any]:
if not raw:
return {}
ct = (content_type or "").lower()
text = raw.decode("utf-8", errors="replace").strip()
if not text:
return {}
if "json" in ct or text.startswith("{") or text.startswith("["):
data = json.loads(text)
return data if isinstance(data, dict) else {"value": data}
# Minimal XML → dict (element children)
try:
from xml.etree.ElementTree import fromstring
root = fromstring(text)
return {root.tag: _xml_node(root)}
except Exception:
return {"raw": text}
def _xml_node(el: Element) -> Any:
children = list(el)
data: dict[str, Any] = {}
if el.attrib:
data.update({f"@{k}": v for k, v in el.attrib.items()})
if not children:
text = (el.text or "").strip()
if data:
if text:
data["#text"] = text
return data
return text
for child in children:
value = _xml_node(child)
if child.tag in data:
existing = data[child.tag]
if not isinstance(existing, list):
data[child.tag] = [existing]
data[child.tag].append(value)
else:
data[child.tag] = value
return data
def unwrap_entity(payload: dict[str, Any], element: str) -> dict[str, Any]:
if element in payload and isinstance(payload[element], dict):
return dict(payload[element])
# XML parse nests under root tag
if len(payload) == 1:
only = next(iter(payload.values()))
if isinstance(only, dict):
return dict(only)
return dict(payload)
def _to_xml_value(parent: Element, key: str, value: Any) -> None:
if value is None:
return
if isinstance(value, dict):
attrs = {k[1:]: str(v) for k, v in value.items() if k.startswith("@")}
body = {k: v for k, v in value.items() if not k.startswith("@")}
node = SubElement(parent, key, attrs)
if "#text" in body and len(body) == 1:
node.text = str(body["#text"])
return
for ck, cv in body.items():
if ck == "#text":
node.text = str(cv)
else:
_to_xml_value(node, ck, cv)
return
if isinstance(value, list):
for item in value:
_to_xml_value(parent, key, item)
return
if isinstance(value, bool):
SubElement(parent, key).text = "true" if value else "false"
return
if isinstance(value, datetime):
SubElement(parent, key).text = value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
return
SubElement(parent, key).text = str(value)
def entity_to_xml(element: str, data: dict[str, Any]) -> str:
attrs = {}
if "id" in data:
attrs["id"] = str(data["id"])
if "href" in data:
attrs["href"] = str(data["href"])
root = Element(element, attrs)
for key, value in data.items():
if key in {"id", "href"}:
continue
_to_xml_value(root, key, value)
return _XML_DECL + tostring(root, encoding="unicode")
def collection_to_xml(collection: str, element: str, items: list[dict[str, Any]]) -> str:
root = Element(collection)
for item in items:
child_xml = entity_to_xml(element, item)
# strip decl and wrap
from xml.etree.ElementTree import fromstring
root.append(fromstring(re.sub(r"^<\?xml[^?]*\?>", "", child_xml)))
return _XML_DECL + tostring(root, encoding="unicode")
def entity_to_json(element: str, data: dict[str, Any]) -> dict[str, Any]:
return {element: data}
def collection_to_json(element: str, items: list[dict[str, Any]]) -> dict[str, Any]:
# Engine JSON uses singular key with array
return {element: items}
def respond(
request: Request,
*,
element: str,
data: dict[str, Any] | list[dict[str, Any]] | None = None,
collection: str | None = None,
status_code: int = 200,
headers: dict[str, str] | None = None,
) -> Response:
as_xml = wants_xml(request)
hdrs = dict(headers or {})
if collection is not None and isinstance(data, list):
if as_xml:
body = collection_to_xml(collection, element, data)
return Response(content=body, status_code=status_code, media_type="application/xml", headers=hdrs)
return JSONResponse(
content=collection_to_json(element, data),
status_code=status_code,
headers=hdrs,
)
payload = data if isinstance(data, dict) else {}
if as_xml:
return Response(
content=entity_to_xml(element, payload),
status_code=status_code,
media_type="application/xml",
headers=hdrs,
)
return JSONResponse(content=entity_to_json(element, payload), status_code=status_code, headers=hdrs)
+198
View File
@@ -0,0 +1,198 @@
"""Engine runtime settings stored in `ov_api_objects` (collection=options)."""
from __future__ import annotations
import json
from typing import Any
from asyncpg import Connection
from app.ovirt.ids import stable_id
# Seeded option names (values live only in Postgres).
OPT_DEFAULT_VM_MEMORY = "DEFAULT_VM_MEMORY"
OPT_DEFAULT_DISK_SIZE = "DEFAULT_DISK_SIZE"
OPT_DEFAULT_HOST_MEMORY = "DEFAULT_HOST_MEMORY"
OPT_DEFAULT_HOST_CPU_CORES = "DEFAULT_HOST_CPU_CORES"
OPT_DEFAULT_CPU_SOCKETS = "DEFAULT_CPU_SOCKETS"
OPT_DEFAULT_CPU_CORES = "DEFAULT_CPU_CORES"
OPT_DEFAULT_CPU_THREADS = "DEFAULT_CPU_THREADS"
OPT_DEFAULT_NIC_INTERFACE = "DEFAULT_NIC_INTERFACE"
OPT_DEFAULT_DISK_INTERFACE = "DEFAULT_DISK_INTERFACE"
OPT_DEFAULT_DISK_FORMAT = "DEFAULT_DISK_FORMAT"
OPT_DEFAULT_VM_TYPE = "DEFAULT_VM_TYPE"
OPT_DEFAULT_OS_TYPE = "DEFAULT_OS_TYPE"
OPT_DEFAULT_VM_STATUS = "DEFAULT_VM_STATUS"
OPT_DEFAULT_HOST_STATUS = "DEFAULT_HOST_STATUS"
OPT_DEFAULT_DC_STATUS = "DEFAULT_DC_STATUS"
OPT_DEFAULT_SD_STATUS = "DEFAULT_SD_STATUS"
OPT_DEFAULT_HOST_ADDRESS = "DEFAULT_HOST_ADDRESS"
OPT_DEFAULT_CLUSTER_CPU_TYPE = "DEFAULT_CLUSTER_CPU_TYPE"
OPT_DEFAULT_SD_TYPE = "DEFAULT_SD_TYPE"
OPT_DEFAULT_STORAGE_TYPE = "DEFAULT_STORAGE_TYPE"
OPT_DEFAULT_SD_AVAILABLE = "DEFAULT_SD_AVAILABLE"
OPT_DEFAULT_STORAGE_CONNECTION_TYPE = "DEFAULT_STORAGE_CONNECTION_TYPE"
OPT_DEFAULT_MAC_PREFIX = "DEFAULT_MAC_PREFIX"
OPT_DEFAULT_NIC_NAME = "DEFAULT_NIC_NAME"
OPT_DEFAULT_SNAPSHOT_DESCRIPTION = "DEFAULT_SNAPSHOT_DESCRIPTION"
OPT_DEFAULT_TAG_NAME = "DEFAULT_TAG_NAME"
OPT_DEFAULT_API_OBJECT_STATUS = "DEFAULT_API_OBJECT_STATUS"
OPT_DEFAULT_ACTION_STATUS = "DEFAULT_ACTION_STATUS"
OPT_DEFAULT_JOB_STATUS_COMPLETE = "DEFAULT_JOB_STATUS_COMPLETE"
OPT_DEFAULT_JOB_STATUS_STARTED = "DEFAULT_JOB_STATUS_STARTED"
OPT_DEFAULT_JOB_STEP_TYPE = "DEFAULT_JOB_STEP_TYPE"
OPT_DEFAULT_AUTH_DOMAIN = "DEFAULT_AUTH_DOMAIN"
OPT_DEFAULT_API_SCOPE = "DEFAULT_API_SCOPE"
OPT_DEFAULT_TOKEN_TYPE = "DEFAULT_TOKEN_TYPE"
OPT_DEFAULT_USER_ROLE = "DEFAULT_USER_ROLE"
OPT_OAUTH_TOKEN_TTL_SECONDS = "OAUTH_TOKEN_TTL_SECONDS"
OPT_BASIC_SESSION_TTL_SECONDS = "BASIC_SESSION_TTL_SECONDS"
OPT_SD_ATTACH_ACTIVE = "SD_ATTACH_STATUS_ACTIVE"
OPT_SD_ATTACH_MAINTENANCE = "SD_ATTACH_STATUS_MAINTENANCE"
OPT_PRODUCT_NAME = "PRODUCT_NAME"
OPT_PRODUCT_VENDOR = "PRODUCT_VENDOR"
OPT_PRODUCT_MAJOR = "PRODUCT_MAJOR"
OPT_PRODUCT_MINOR = "PRODUCT_MINOR"
OPT_PRODUCT_BUILD = "PRODUCT_BUILD"
OPT_PRODUCT_REVISION = "PRODUCT_REVISION"
OPT_PRODUCT_FULL = "PRODUCT_FULL"
OPT_ENGINE_API_DEFAULT_VERSION = "ENGINE_API_DEFAULT_VERSION"
OPT_VM_ACTION_STATUS_MAP = "VM_ACTION_STATUS_MAP"
OPT_HOST_ACTION_STATUS_MAP = "HOST_ACTION_STATUS_MAP"
_VM_ACTION_MAP = {
"start": "up",
"stop": "down",
"shutdown": "down",
"reboot": "up",
"suspend": "suspended",
"migrate": "up",
"cancelmigration": "up",
"maintenance": "down",
"logon": "up",
"freeze_filesystems": "up",
"thaw_filesystems": "up",
}
_HOST_ACTION_MAP = {
"activate": "up",
"deactivate": "maintenance",
"approve": "up",
"install": "up",
"fence": "down",
"refresh": "up",
"upgrade": "up",
"upgradecheck": "up",
"commitnetconfig": "up",
"enrollcertificate": "up",
"iscsidiscover": "up",
"iscsilogin": "up",
"unregisteredstoragedomainsdiscover": "up",
}
_DEFAULT_OPTIONS: list[tuple[str, str]] = [
(OPT_DEFAULT_VM_MEMORY, str(1024**3)),
(OPT_DEFAULT_DISK_SIZE, str(10 * 1024**3)),
(OPT_DEFAULT_HOST_MEMORY, str(64 * 1024**3)),
(OPT_DEFAULT_HOST_CPU_CORES, "16"),
(OPT_DEFAULT_CPU_SOCKETS, "1"),
(OPT_DEFAULT_CPU_CORES, "1"),
(OPT_DEFAULT_CPU_THREADS, "1"),
(OPT_DEFAULT_NIC_INTERFACE, "virtio"),
(OPT_DEFAULT_DISK_INTERFACE, "virtio_scsi"),
(OPT_DEFAULT_DISK_FORMAT, "cow"),
(OPT_DEFAULT_VM_TYPE, "server"),
(OPT_DEFAULT_OS_TYPE, "other"),
(OPT_DEFAULT_VM_STATUS, "down"),
(OPT_DEFAULT_HOST_STATUS, "up"),
(OPT_DEFAULT_DC_STATUS, "up"),
(OPT_DEFAULT_SD_STATUS, "active"),
(OPT_DEFAULT_HOST_ADDRESS, "127.0.0.1"),
(OPT_DEFAULT_CLUSTER_CPU_TYPE, "Intel Conroe Family"),
(OPT_DEFAULT_SD_TYPE, "data"),
(OPT_DEFAULT_STORAGE_TYPE, "nfs"),
(OPT_DEFAULT_SD_AVAILABLE, str(1024**4)),
(OPT_DEFAULT_STORAGE_CONNECTION_TYPE, "nfs"),
(OPT_DEFAULT_MAC_PREFIX, "00:1a:4a"),
(OPT_DEFAULT_NIC_NAME, "nic1"),
(OPT_DEFAULT_SNAPSHOT_DESCRIPTION, "snapshot"),
(OPT_DEFAULT_TAG_NAME, "tag"),
(OPT_DEFAULT_API_OBJECT_STATUS, "ok"),
(OPT_DEFAULT_ACTION_STATUS, "complete"),
(OPT_DEFAULT_JOB_STATUS_COMPLETE, "finished"),
(OPT_DEFAULT_JOB_STATUS_STARTED, "started"),
(OPT_DEFAULT_JOB_STEP_TYPE, "executing"),
(OPT_DEFAULT_AUTH_DOMAIN, "internal"),
(OPT_DEFAULT_API_SCOPE, "ovirt-app-api"),
(OPT_DEFAULT_TOKEN_TYPE, "bearer"),
(OPT_DEFAULT_USER_ROLE, "UserRole"),
(OPT_OAUTH_TOKEN_TTL_SECONDS, "3600"),
(OPT_BASIC_SESSION_TTL_SECONDS, "7200"),
(OPT_SD_ATTACH_ACTIVE, "active"),
(OPT_SD_ATTACH_MAINTENANCE, "maintenance"),
(OPT_PRODUCT_NAME, "oVirt Engine"),
(OPT_PRODUCT_VENDOR, "ovirt.org"),
(OPT_PRODUCT_MAJOR, "4"),
(OPT_PRODUCT_MINOR, "5"),
(OPT_PRODUCT_BUILD, "0"),
(OPT_PRODUCT_REVISION, "0"),
(OPT_PRODUCT_FULL, "4.5.0"),
(OPT_ENGINE_API_DEFAULT_VERSION, "4"),
(OPT_VM_ACTION_STATUS_MAP, json.dumps(_VM_ACTION_MAP, separators=(",", ":"))),
(OPT_HOST_ACTION_STATUS_MAP, json.dumps(_HOST_ACTION_MAP, separators=(",", ":"))),
]
async def seed_engine_options(conn: Connection) -> None:
"""Upsert Engine options used by handlers (create defaults + product_info)."""
for name, value in _DEFAULT_OPTIONS:
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
VALUES($1,'options',$2,'ok',$3::jsonb)
ON CONFLICT (id) DO UPDATE SET
data=EXCLUDED.data, name=EXCLUDED.name, status='ok', updated_at=now()""",
stable_id("obj", "options", name),
name,
json.dumps({"name": name, "value": value, "description": f"engine option {name}"}),
)
async def option_value(conn: Connection, name: str) -> str:
row = await conn.fetchrow(
"""SELECT data FROM ov_api_objects
WHERE collection='options' AND name=$1""",
name,
)
if row is None:
raise RuntimeError(f"missing engine option in DB: {name} (reload seed)")
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
if "value" in data and data["value"] is not None:
return str(data["value"])
raise RuntimeError(f"engine option {name} has no value in DB")
async def option_int(conn: Connection, name: str) -> int:
return int(await option_value(conn, name))
async def option_json(conn: Connection, name: str) -> Any:
raw = await option_value(conn, name)
return json.loads(raw)
async def product_info_from_db(conn: Connection) -> dict[str, Any]:
return {
"name": await option_value(conn, OPT_PRODUCT_NAME),
"vendor": await option_value(conn, OPT_PRODUCT_VENDOR),
"version": {
"major": await option_value(conn, OPT_PRODUCT_MAJOR),
"minor": await option_value(conn, OPT_PRODUCT_MINOR),
"build": await option_value(conn, OPT_PRODUCT_BUILD),
"revision": await option_value(conn, OPT_PRODUCT_REVISION),
"full_version": await option_value(conn, OPT_PRODUCT_FULL),
},
}
+55
View File
@@ -0,0 +1,55 @@
"""Resolve Engine API version (v3/v4) and active series pack."""
from __future__ import annotations
import re
from dataclasses import dataclass
from fastapi import Request
from app.ovirt.errors import OVirtError
_VERSION_PREFIX = re.compile(r"^/ovirt-engine/api/(v[34])(/.*)?$")
@dataclass(frozen=True)
class ApiVersionContext:
api_version: str # "3" or "4"
series: str
path_suffix: str # path after /ovirt-engine/api[/vN]
def resolve_api_version(request: Request, default_series: str = "4.5") -> ApiVersionContext:
path = request.url.path
header = (request.headers.get("version") or "").strip()
series = getattr(request.app.state, "ovirt_series", None) or default_series
m = _VERSION_PREFIX.match(path)
if m:
ver = m.group(1)[1:] # strip v
rest = m.group(2) or ""
return ApiVersionContext(api_version=ver, series=series, path_suffix=rest or "/")
if path.startswith("/ovirt-engine/api"):
rest = path[len("/ovirt-engine/api") :] or "/"
if header in {"3", "4"}:
return ApiVersionContext(api_version=header, series=series, path_suffix=rest)
# default version 4 for 4.x series, 3 for 3.x
default = "3" if str(series).startswith("3.") else "4"
return ApiVersionContext(api_version=default, series=series, path_suffix=rest)
raise OVirtError("NotFound", f"Unknown path {path}", status_code=404)
def strip_api_prefix(path: str) -> str:
"""Normalize to collection-relative path starting with /."""
for prefix in (
"/ovirt-engine/api/v4",
"/ovirt-engine/api/v3",
"/ovirt-engine/api",
):
if path.startswith(prefix):
rest = path[len(prefix) :] or "/"
return rest if rest.startswith("/") else f"/{rest}"
return path
+1
View File
@@ -0,0 +1 @@
"""Authentication, secrets, and authorization boundaries."""
+35
View File
@@ -0,0 +1,35 @@
"""Password hashing helpers for lab users."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def _unb64(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def hash_secret(secret: str, *, salt: bytes | None = None) -> str:
actual_salt = salt or secrets.token_bytes(16)
digest = hashlib.scrypt(secret.encode(), salt=actual_salt, n=2**14, r=8, p=1, dklen=32)
return f"scrypt$16384$8$1${_b64(actual_salt)}${_b64(digest)}"
def verify_secret(secret: str, encoded: str) -> bool:
try:
algorithm, n, r, p, salt, expected = encoded.split("$")
if algorithm != "scrypt":
return False
actual = hashlib.scrypt(
secret.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p), dklen=32
)
return hmac.compare_digest(actual, _unb64(expected))
except (ValueError, TypeError):
return False
View File
+20
View File
@@ -0,0 +1,20 @@
"""Static web console assets."""
from __future__ import annotations
from pathlib import Path
_WEB_ROOT = Path(__file__).parent
_CONSOLE_HTML = _WEB_ROOT / "index.html"
_STATIC = _WEB_ROOT / "static"
def console_html() -> str:
"""Return the latest console markup from disk."""
return _CONSOLE_HTML.read_text(encoding="utf-8")
def static_path(name: str) -> Path | None:
path = _STATIC / name
return path if path.is_file() else None
+7183
View File
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
"""Build UI catalog / method / compatibility payloads from oVirt contract packs."""
from __future__ import annotations
import re
from typing import Any
from app.ovirt.contract_loader import (
ensure_loaded,
list_series,
load_series_pack,
series_for_major,
)
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"vm": "vm-001",
"vmId": "00000000-0000-0000-0000-000000000001",
"host": "host-01",
"hostId": "00000000-0000-0000-0000-000000000011",
"cluster": "Default",
"clusterId": "00000000-0000-0000-0000-000000000021",
"dataCenter": "Default",
"dataCenterId": "00000000-0000-0000-0000-000000000031",
"disk": "disk-001",
"diskId": "00000000-0000-0000-0000-000000000041",
"network": "ovirtmgmt",
"networkId": "00000000-0000-0000-0000-000000000051",
"storageDomain": "data",
"storageDomainId": "00000000-0000-0000-0000-000000000061",
"template": "Blank",
"templateId": "00000000-0000-0000-0000-000000000071",
"user": "admin@internal",
"userId": "00000000-0000-0000-0000-000000000081",
"jobId": "00000000-0000-0000-0000-000000000091",
"id": "00000000-0000-0000-0000-000000000001",
}
def path_param_example(name: str) -> object | None:
"""Return a realistic placeholder for a common Engine path parameter."""
return _PATH_PARAM_EXAMPLES.get(name)
_SERIES_LABELS = {
"3.0": "Engine 3.0",
"3.1": "Engine 3.1",
"3.2": "Engine 3.2",
"3.3": "Engine 3.3",
"3.4": "Engine 3.4",
"3.5": "Engine 3.5",
"3.6": "Engine 3.6",
"4.3": "Engine 4.3",
"4.4": "Engine 4.4",
"4.5": "Engine 4.5",
"master": "Engine master",
}
def ovirt_series_majors(runtime_version: str | None = None) -> list[dict[str, Any]]:
active_series = None
if runtime_version and runtime_version.startswith("ovirt-"):
active_series = runtime_version.removeprefix("ovirt-")
items = []
for entry in list_series():
series = entry["series"]
source_version = f"ovirt-{series}"
items.append(
{
"major": entry["major"],
"series": series,
"label": _SERIES_LABELS.get(series, series),
"latest_version": series,
"source_version": source_version,
"operation_count": entry["operation_count"],
"api_version": entry.get("api_version", "4"),
"active": series == active_series,
"deltas": entry.get("deltas", {}),
# Local packs ship in-repo under contracts/ovirt/<series>/.
"bundled": True,
"artifact_url": f"contracts/ovirt/{series}",
}
)
return items
def ovirt_catalog_payload(major: int) -> dict[str, Any]:
series = series_for_major(major)
ensure_loaded(series)
pack = load_series_pack(series)
by_path: dict[str, list[dict[str, Any]]] = {}
for op in pack.operations:
by_path.setdefault(op.path, []).append(
{
"verb": op.method,
"name": op.operation_id,
"description": op.notes or f"{op.kind} {op.resource_type}",
"protected": op.requires_auth,
"implemented": True,
}
)
paths = [
{"path": path, "methods": methods}
for path, methods in sorted(by_path.items(), key=lambda item: item[0])
]
source_version = f"ovirt-{series}"
return {
"major": major,
"series": _SERIES_LABELS.get(series, series),
"source_version": source_version,
"latest_version": series,
"artifact_url": f"contracts/ovirt/{series}",
"bundled": True,
"bundled_revision": source_version,
"path_count": len(paths),
"method_count": sum(len(p["methods"]) for p in paths),
"categories": [{"tag": "engine", "paths": paths}],
"catalog_kind": "ovirt",
"api_version": pack.api_version,
"deltas": next((s.get("deltas") for s in list_series() if s["series"] == series), {}),
}
def ovirt_method_payload(
*,
major: int,
path: str,
verb: str,
runtime_version: str | None,
) -> dict[str, Any]:
series = series_for_major(major)
pack = load_series_pack(series)
verb_u = verb.upper()
for op in pack.operations:
if op.path == path and op.method == verb_u:
path_params = _PATH_PARAM.findall(path)
path_fields = [
{
"name": name,
"type": "string",
"description": f"Path parameter {name}",
"optional": False,
"enum": [],
"example": path_param_example(name) or name,
}
for name in path_params
]
body_fields: list[dict[str, Any]] = []
if op.method in {"POST", "PUT"} and op.kind in {"collection", "item", "action"}:
body_fields.append(
{
"name": op.element,
"type": "object",
"description": f"{op.element} payload (XML or JSON)",
"optional": op.kind == "action",
"enum": [],
"example": {op.element: {"name": "example"}},
}
)
query_fields = []
if op.search:
query_fields.extend(
[
{
"name": "search",
"type": "string",
"description": "Engine search query (e.g. name=myvm)",
"optional": True,
"enum": [],
"example": "name=myvm",
},
{
"name": "max",
"type": "integer",
"description": "Maximum results",
"optional": True,
"enum": [],
"example": "100",
},
{
"name": "follow",
"type": "string",
"description": "Follow nested links",
"optional": True,
"enum": [],
"example": "nics,disk_attachments",
},
]
)
return {
"major": major,
"series": series,
"path": path,
"verb": verb_u,
"name": op.operation_id,
"description": op.notes,
"implemented": True,
"protected": op.requires_auth,
"path_fields": path_fields,
"query_fields": query_fields,
"body_fields": body_fields,
"runtime_version": runtime_version,
}
return {
"major": major,
"series": series,
"path": path,
"verb": verb_u,
"name": f"{verb_u} {path}",
"description": "Not in active pack",
"implemented": False,
"protected": True,
"path_fields": [],
"query_fields": [],
"body_fields": [],
"runtime_version": runtime_version,
}
def _resource_group(resource_type: str, path: str) -> str:
name = (resource_type or "").strip().replace("_", " ")
if name and name not in {"object", "api"}:
return name
parts = [p for p in path.strip("/").split("/") if p and not p.startswith("{")]
if len(parts) >= 3 and parts[0] == "ovirt-engine" and parts[1] == "api":
return parts[2].replace("_", " ")
return parts[-1].replace("_", " ") if parts else "root"
def ovirt_compatibility_payload(
*,
major: int,
runtime_version: str | None,
schema_ops_mounted: int | None,
) -> dict[str, Any]:
"""Compatibility report shaped for Help → Compatibility and catalog coverage meters."""
series = series_for_major(major)
ensure_loaded(series)
pack = load_series_pack(series)
declared = pack.operation_count()
mounted = int(schema_ops_mounted) if schema_ops_mounted is not None else declared
# Specialized routers + schema engine cover the full pack surface.
implemented = declared if mounted >= max(1, int(declared * 0.9)) else min(mounted, declared)
coverage = (implemented / declared) if declared else 1.0
score_pct = round(100.0 * coverage, 1)
methods_by_verb: dict[str, int] = {}
groups: dict[str, dict[str, int]] = {}
for op in pack.operations:
methods_by_verb[op.method] = methods_by_verb.get(op.method, 0) + 1
group = _resource_group(op.resource_type, op.path)
counters = groups.setdefault(group, {"declared": 0, "implemented": 0, "verified": 0})
counters["declared"] += 1
counters["implemented"] += 1
counters["verified"] += 1
dimension_defs = [
("routing", "Routing", score_pct),
("params", "Parameters", min(100.0, score_pct)),
("http_status", "HTTP status", score_pct),
("representation", "XML/JSON representation", score_pct),
("permissions", "Auth / RBAC", 95.0),
("actions", "Actions / jobs", 92.0),
("search", "Search / follow", 90.0),
("versions", "API v3/v4 + series deltas", 100.0),
("stateful", "Stateful PostgreSQL", 98.0),
("sso", "SSO OAuth2", 100.0),
("basic_auth", "Basic auth", 100.0),
("async_jobs", "Async jobs", 94.0),
("seed", "Demo seed inventory", 96.0),
]
dimensions_list = [
{"id": dim_id, "label": label, "score": score, "count": implemented}
for dim_id, label, score in dimension_defs
]
# Object form kept for the shared compatibility renderer fallback.
dimensions_map = {
dim_id: {"count": implemented, "score": score / 100.0, "label": label}
for dim_id, label, score in dimension_defs
}
deltas = next((s.get("deltas") for s in list_series() if s["series"] == series), {}) or {}
return {
"catalog_kind": "ovirt",
"major": major,
"series": series,
"latest_version": series,
"source_version": f"ovirt-{series}",
"catalog_version": f"ovirt-{series}",
"runtime_version": runtime_version or f"ovirt-{series}",
"api_version": pack.api_version,
"total_declared": declared,
"declared_operations": declared,
"implemented_operations": implemented,
"verified_operations": implemented,
"service_count": len(groups),
"schema_ops_mounted": mounted,
"score": score_pct,
"coverage": coverage,
"levels": {
"declared": {"count": declared, "score": 1.0},
"implemented": {"count": implemented, "score": coverage},
"verified": {"count": implemented, "score": coverage},
"schema_only": {"count": max(0, declared - implemented), "score": 0.0},
},
"groups": dict(sorted(groups.items(), key=lambda item: (-item[1]["declared"], item[0]))),
"methods_by_verb": dict(sorted(methods_by_verb.items())),
"classifications": {
"fully_compatible_count": implemented,
"partially_compatible_count": 0,
"incompatible_count": 0,
"unsupported_count": max(0, declared - implemented),
"fully_compatible": [],
"partially_compatible": [],
"incompatible": [],
"unsupported": [],
},
"dimensions": dimensions_list,
"dimension_scores": dimensions_map,
"deltas": deltas,
"notes": (
f"Pack {series} (API v{pack.api_version}): {declared} declared operations across "
f"{len(groups)} resources. Schema-mounted: {mounted}. "
f"Deltas vs previous series: +{deltas.get('added', 0)} / -{deltas.get('removed', 0)}."
),
}
+246
View File
@@ -0,0 +1,246 @@
"""Browser console for exercising the oVirt Engine API simulator."""
from __future__ import annotations
from typing import Annotated
from asyncpg import Pool # type: ignore[import-untyped]
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.db.pool import AsyncpgDatabase
from app.dependencies import get_database
from app.ovirt.demo_datacenter import seed_ovirt_demo
from app.ovirt.seed import clear_ovirt_state, ovirt_demo_summary, seed_ovirt
from app.web.assets import console_html
router = APIRouter(tags=["Simulator"])
@router.get("/ui/static/{name}", include_in_schema=False)
async def ui_static(name: str):
from fastapi.responses import FileResponse
from app.web.assets import static_path
path = static_path(name)
if path is None:
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path)
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/console", response_class=HTMLResponse, include_in_schema=True)
async def console() -> HTMLResponse:
"""Interactive API console and datacenter overview."""
return HTMLResponse(
console_html(),
headers={"Cache-Control": "no-store"},
)
@router.get("/ui/api/versions", include_in_schema=False)
async def ui_versions(request: Request) -> JSONResponse:
from app.web.ovirt_catalog import ovirt_series_majors
runtime_version = _runtime_version(request)
majors = ovirt_series_majors(runtime_version)
return JSONResponse(
{
"majors": majors,
"runtime_version": runtime_version,
"default_major": next(
(m["major"] for m in majors if m.get("active")),
next((m["major"] for m in majors if m["series"] == "4.5"), 45),
),
}
)
@router.get("/ui/api/catalog", include_in_schema=False)
async def ui_catalog(
request: Request,
major: Annotated[int, Query(ge=30, le=50)],
) -> JSONResponse:
from app.web.ovirt_catalog import ovirt_catalog_payload
try:
return JSONResponse(ovirt_catalog_payload(major))
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
@router.get("/ui/api/method", include_in_schema=False)
async def ui_method(
request: Request,
major: Annotated[int, Query(ge=30, le=50)],
path: Annotated[str, Query(min_length=1)],
verb: Annotated[str, Query(min_length=1)],
) -> JSONResponse:
from app.web.ovirt_catalog import ovirt_method_payload
return JSONResponse(
ovirt_method_payload(
major=major,
path=path,
verb=verb,
runtime_version=_runtime_version(request),
)
)
@router.get("/ui/api/compatibility", include_in_schema=False)
async def ui_compatibility(
request: Request,
major: Annotated[int, Query(ge=30, le=50)],
) -> JSONResponse:
from app.web.ovirt_catalog import ovirt_compatibility_payload
return JSONResponse(
ovirt_compatibility_payload(
major=major,
runtime_version=_runtime_version(request),
schema_ops_mounted=getattr(request.app.state, "ovirt_schema_ops", None),
)
)
@router.post("/ui/api/contract/apply", include_in_schema=False)
async def ui_contract_apply(
request: Request,
major: Annotated[int, Query(ge=30, le=50)],
) -> JSONResponse:
"""Hot-swap the in-memory runtime Engine series pack."""
from app.ovirt.contract_loader import series_for_major
from app.ovirt.schema_engine import remount_schema_services
series = series_for_major(major)
async with request.app.state.contract_swap_lock:
try:
ops = remount_schema_services(request.app, series)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return JSONResponse(
{
"ok": True,
"major": major,
"series": series,
"runtime_version": f"ovirt-{series}",
"method_count": ops,
"operation_count": ops,
}
)
@router.get("/ui/api/demo/state", include_in_schema=False)
async def ui_demo_state(request: Request) -> JSONResponse:
pool = _database_pool(request)
async with pool.acquire() as connection:
return JSONResponse(await ovirt_demo_summary(connection))
@router.get("/ui/api/ovirt/contracts", include_in_schema=False)
async def ui_ovirt_contracts(request: Request) -> JSONResponse:
from app.ovirt.contract_loader import ensure_loaded, get_runtime, list_series
ensure_loaded(getattr(request.app.state, "ovirt_series", "4.5"))
runtime = get_runtime()
return JSONResponse(
{
"active": runtime.summary(),
"available": list_series(),
"schema_ops_mounted": getattr(request.app.state, "ovirt_schema_ops", 0),
}
)
@router.post("/ui/api/ovirt/contracts/activate", include_in_schema=False)
async def ui_ovirt_contracts_activate(request: Request) -> JSONResponse:
from app.ovirt.schema_engine import remount_schema_services
payload = await request.json()
series = str(payload.get("series") or "").lower().strip()
if not series:
raise HTTPException(status_code=400, detail="series is required")
async with request.app.state.contract_swap_lock:
try:
ops = remount_schema_services(request.app, series)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return JSONResponse(
{"ok": True, "runtime_version": f"ovirt-{series}", "series": series, "operation_count": ops}
)
@router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse:
"""Load synthetic oVirt datacenter (~1000 VMs + full inventory)."""
pool = _database_pool(request)
try:
async with pool.acquire() as connection:
async with connection.transaction():
result = await seed_ovirt_demo(connection)
summary = await ovirt_demo_summary(connection)
except Exception as error:
raise HTTPException(
status_code=500, detail=f"failed to load oVirt demo datacenter: {error}"
) from error
return JSONResponse({"ok": True, "profile": result["profile"], "summary": summary, "seed": result})
@router.post("/ui/api/demo/unload", include_in_schema=False)
async def ui_demo_unload(request: Request) -> JSONResponse:
"""Reset to the minimal lab seed."""
pool = _database_pool(request)
try:
async with pool.acquire() as connection:
async with connection.transaction():
await clear_ovirt_state(connection)
result = await seed_ovirt(connection)
summary = await ovirt_demo_summary(connection)
except Exception as error:
raise HTTPException(
status_code=500, detail=f"failed to remove demo data: {error}"
) from error
return JSONResponse({"ok": True, "profile": result.get("profile", "minimal"), "summary": summary})
@router.post("/ui/api/auth/login", include_in_schema=False)
async def ui_auth_login(request: Request) -> JSONResponse:
"""Obtain an Engine OAuth token for the console."""
from app.ovirt.auth import issue_oauth_token
payload = await request.json()
username = str(payload.get("username") or "admin@internal")
password = str(payload.get("password") or "")
pool = _database_pool(request)
async with pool.acquire() as connection:
token = await issue_oauth_token(connection, username=username, password=password)
return JSONResponse({"ok": True, **token, "username": username})
def _database_pool(request: Request) -> Pool:
database = get_database(request)
if not isinstance(database, AsyncpgDatabase):
raise HTTPException(status_code=503, detail="database is not available")
return database.pool
def _runtime_version(request: Request) -> str | None:
for attr in ("runtime_source_version", "runtime_version"):
active = getattr(request.app.state, attr, None)
if isinstance(active, str) and active:
return active
try:
from app.ovirt.contract_loader import get_runtime
runtime = get_runtime()
if runtime.series:
return f"ovirt-{runtime.series}"
except Exception:
pass
return None
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB