cbd0adca91
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""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)
|