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:
@@ -0,0 +1 @@
|
||||
"""oVirt / RHV Engine API simulator application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP adapters."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""PostgreSQL infrastructure."""
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
);
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed PostgreSQL repositories for simulation domain state."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Health, metrics, and tracing adapters."""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Specialized oVirt Engine route modules."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication, secrets, and authorization boundaries."""
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)}."
|
||||
),
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user