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,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)
|
||||
Reference in New Issue
Block a user