Initial release of the oVirt/RHV Engine API simulator.

Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release
targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
2026-07-18 04:49:28 +03:00
commit cbd0adca91
218 changed files with 246804 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
"""Generic surface-complete CRUD backed by ov_api_objects for undeclared collections."""
from __future__ import annotations
import json
from typing import Any
from uuid import uuid4
from asyncpg import Connection
from fastapi import Request, Response
from app.ovirt.errors import OVirtError
from app.ovirt.repr import generic_entity
from app.ovirt.serialize import respond, unwrap_entity
# Map collection path segment → (element singular, default status)
_COLLECTIONS: dict[str, tuple[str, str]] = {
"affinitylabels": ("affinity_label", "ok"),
"bookmarks": ("bookmark", "ok"),
"clusterlevels": ("cluster_level", "ok"),
"domains": ("domain", "ok"),
"externalhostproviders": ("external_host_provider", "ok"),
"groups": ("group", "ok"),
"icons": ("icon", "ok"),
"instancetypes": ("instance_type", "ok"),
"katelloerrata": ("katello_erratum", "ok"),
"macpools": ("mac_pool", "ok"),
"networkfilters": ("network_filter", "ok"),
"openstackimageproviders": ("openstack_image_provider", "ok"),
"openstacknetworkproviders": ("openstack_network_provider", "ok"),
"openstackvolumeproviders": ("openstack_volume_provider", "ok"),
"operatingsystems": ("operating_system", "ok"),
"permissions": ("permission", "ok"),
"roles": ("role", "ok"),
"schedulingpolicies": ("scheduling_policy", "ok"),
"schedulingpolicyunits": ("scheduling_policy_unit", "ok"),
"tags": ("tag", "ok"),
"vmpools": ("vm_pool", "ok"),
"imagetransfers": ("image_transfer", "ok"),
"options": ("engine_option", "ok"),
"networklabels": ("network_label", "ok"),
"cpuprofiles": ("cpu_profile", "ok"),
"diskprofiles": ("disk_profile", "ok"),
"qoss": ("qos", "ok"),
"iscsibonds": ("iscsi_bond", "ok"),
"glustervolumes": ("gluster_volume", "ok"),
"files": ("file", "ok"),
"images": ("image", "ok"),
"permits": ("permit", "ok"),
"filters": ("filter", "ok"),
"weights": ("weight", "ok"),
"balances": ("balance", "ok"),
"enabledfeatures": ("cluster_enabled_feature", "ok"),
"cdroms": ("cdrom", "ok"),
"graphicsconsoles": ("graphics_console", "ok"),
"reporteddevices": ("reported_device", "ok"),
"sessions": ("session", "ok"),
"applications": ("application", "ok"),
"watchdogs": ("watchdog", "ok"),
"hostdevices": ("host_device", "ok"),
"numanodes": ("numa_node", "ok"),
"mediateddevices": ("vm_mediated_device", "ok"),
"statistics": ("statistic", "ok"),
"hooks": ("hook", "ok"),
"devices": ("host_device", "ok"),
"sshpublickeys": ("ssh_public_key", "ok"),
"networkfilterparameters": ("network_filter_parameter", "ok"),
}
def _meta(collection: str) -> tuple[str, str]:
return _COLLECTIONS.get(collection, (collection.rstrip("s") or "object", "ok"))
async def handle_generic(
request: Request,
conn: Connection,
method: str,
parts: list[str],
payload: dict[str, Any],
) -> Response:
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
collection = parts[0]
element, _catalog_status = _meta(collection)
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
if len(parts) == 1:
if method == "GET":
rows = await conn.fetch(
"SELECT * FROM ov_api_objects WHERE collection=$1 ORDER BY name", collection
)
items = [generic_entity(collection, element, r) for r in rows]
return respond(request, element=element, collection=collection, data=items)
if method == "POST":
body = unwrap_entity(payload, element)
oid = uuid4()
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
VALUES($1,$2,$3,$4,$5::jsonb)""",
oid,
collection,
name,
str(body.get("status") or default_status),
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
return respond(
request, element=element, data=generic_entity(collection, element, row), status_code=201
)
if len(parts) == 2:
oid = parts[1]
row = await conn.fetchrow(
"SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
)
if method == "GET":
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
return respond(request, element=element, data=generic_entity(collection, element, row))
if method == "PUT":
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
body = unwrap_entity(payload, element)
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
data.update(body)
await conn.execute(
"""UPDATE ov_api_objects SET name=COALESCE($2,name), data=$3::jsonb, updated_at=now()
WHERE id=$1::uuid""",
oid,
body.get("name"),
json.dumps(data),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
return respond(request, element=element, data=generic_entity(collection, element, row))
if method == "DELETE":
await conn.execute(
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
)
return Response(status_code=200)
if len(parts) == 3 and method == "POST":
from app.ovirt.jobs import respond_action
return await respond_action(
request, conn, description=f"{collection} {parts[2]}"
)
if len(parts) >= 3:
return await handle_subcollection(
request, conn, method, collection, parts[1], parts[2], parts[3:], payload
)
raise OVirtError("NotFound", f"No handler for /{'/'.join(parts)}", status_code=404)
_PARENT_OBJECT_TYPE: dict[str, str] = {
"vms": "vm",
"hosts": "host",
"disks": "disk",
"datacenters": "data_center",
"clusters": "cluster",
"networks": "network",
"storagedomains": "storage_domain",
"templates": "template",
"users": "user",
"groups": "group",
"vnicprofiles": "vnic_profile",
"vmpools": "vm_pool",
}
async def handle_subcollection(
request: Request,
conn: Connection,
method: str,
parent_collection: str,
parent_id: str,
sub: str,
rest: list[str],
payload: dict[str, Any],
) -> Response:
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
element, _catalog_status = _meta(sub)
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
collection_key = sub
if not rest and method == "GET" and sub == "permissions":
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
rows = await conn.fetch(
"""SELECT p.*, r.name AS role_name, u.name AS user_name
FROM ov_permissions p
JOIN ov_roles r ON r.id = p.role_id
LEFT JOIN ov_users u ON u.id = p.user_id
WHERE p.object_type=$1 AND p.object_id=$2::uuid
ORDER BY r.name""",
object_type,
parent_id,
)
items = [
{
"id": str(r["id"]),
"href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}",
"role": {"id": str(r["role_id"]), "name": r["role_name"]},
"object": {"type": r["object_type"], "id": str(parent_id)},
**(
{"user": {"id": str(r["user_id"]), "name": r["user_name"]}}
if r["user_id"] is not None
else {}
),
}
for r in rows
]
return respond(request, element="permission", collection="permissions", data=items)
if not rest and method == "GET" and sub == "tags":
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
rows = await conn.fetch(
"""SELECT t.*
FROM ov_tag_assignments a
JOIN ov_tags t ON t.id = a.tag_id
WHERE a.object_type=$1 AND a.object_id=$2::uuid
ORDER BY t.name""",
object_type,
parent_id,
)
items = [
{
"id": str(r["id"]),
"href": f"/ovirt-engine/api/tags/{r['id']}",
"name": r["name"],
"description": r["description"] or "",
}
for r in rows
]
return respond(request, element="tag", collection="tags", data=items)
if not rest:
if method == "GET":
rows = await conn.fetch(
"""SELECT * FROM ov_api_objects
WHERE collection=$1 AND parent_collection=$2 AND parent_id=$3::uuid
ORDER BY name""",
collection_key,
parent_collection,
parent_id,
)
items = [
generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, r) for r in rows
]
# Fix hrefs
for item, row in zip(items, rows, strict=False):
item["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{row['id']}"
return respond(request, element=element, collection=sub, data=items)
if method == "POST":
body = unwrap_entity(payload, element)
oid = uuid4()
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
await conn.execute(
"""INSERT INTO ov_api_objects(id, collection, name, status, parent_collection, parent_id, data)
VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)""",
oid,
collection_key,
name,
str(body.get("status") or default_status),
parent_collection,
parent_id,
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
data = generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, row)
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
return respond(request, element=element, data=data, status_code=201)
if len(rest) == 1:
oid = rest[0]
if method == "GET":
row = await conn.fetchrow(
"""SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2
AND parent_id=$3::uuid""",
oid,
collection_key,
parent_id,
)
if row is None:
raise OVirtError("NotFound", f"{element} not found", status_code=404)
data = generic_entity(sub, element, row)
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
return respond(request, element=element, data=data)
if method == "DELETE":
await conn.execute(
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND parent_id=$2::uuid",
oid,
parent_id,
)
return Response(status_code=200)
if method == "PUT":
body = unwrap_entity(payload, element)
await conn.execute(
"UPDATE ov_api_objects SET data=$2::jsonb, updated_at=now() WHERE id=$1::uuid",
oid,
json.dumps(body),
)
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
return respond(request, element=element, data=generic_entity(sub, element, row))
if len(rest) == 2 and method == "POST":
from app.ovirt.jobs import respond_action
return await respond_action(
request, conn, description=f"{parent_collection}/{sub} {rest[1]}"
)
raise OVirtError(
"NotFound",
f"No handler for /{parent_collection}/{parent_id}/{sub}/{'/'.join(rest)}",
status_code=404,
)
def remount_schema_services(app: Any, series: str) -> int:
"""Hot-swap contract pack: reload ops and re-register OpenAPI routes."""
from app.ovirt.contract_loader import ensure_loaded
from app.ovirt.registry import clear_ovirt_contract_routes, register_ovirt_contract_routes
from app.ovirt.routes import engine
rt = ensure_loaded(series)
summary = rt.reload(series)
clear_ovirt_contract_routes(app)
registered = 0
if rt.pack is not None:
registered = register_ovirt_contract_routes(app, rt.pack)
# Re-attach catch-all after specific contract routes (match order matters).
app.include_router(engine.router)
app.state.ovirt_series = series
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
app.state.runtime_version = f"ovirt-{series}"
return int(app.state.ovirt_schema_ops)