"""Schema-driven OpenStack API engine — surface-complete ops from contract packs.""" from __future__ import annotations import json import re from datetime import UTC, datetime from typing import Any from uuid import UUID, uuid4 from asyncpg import Connection from fastapi import APIRouter, Request, Response from fastapi.responses import JSONResponse from app.db.pool import AsyncpgDatabase from app.openstack.auth import TokenContext, extract_token, validate_token from app.openstack.contract_loader import ensure_loaded, get_runtime from app.openstack.errors import OpenStackError from app.openstack.opspec import OperationSpec, ServicePack _PATH_PARAM = re.compile(r"\{([^{}]+)\}") def _singular(collection_key: str) -> str: if collection_key.endswith("ies"): return collection_key[:-3] + "y" if collection_key.endswith("ses"): return collection_key[:-2] if collection_key.endswith("s") and not collection_key.endswith("ss"): return collection_key[:-1] return collection_key def _fastapi_path(path: str) -> str: """Convert {param} to FastAPI {param} (already compatible).""" return path if path.startswith("/") else f"/{path}" def _parent_scope(path: str, path_params: dict[str, str]) -> dict[str, str]: parent = {k: v for k, v in path_params.items() if k != "id"} # Nested collections like /resource_providers/{id}/inventories keep the parent # id under useful aliases so list filters can match seeded child rows. if "id" in path_params: match = re.search(r"/([^/]+)/\{id\}(?:/|$)", path) if match: segment = match.group(1) singular = ( segment[:-1] if segment.endswith("s") and not segment.endswith("ss") else segment ) parent.setdefault(f"{singular}_id", path_params["id"]) parent.setdefault(singular, path_params["id"]) parent.setdefault("parent_id", path_params["id"]) parent.setdefault("resource_provider", path_params["id"]) parent.setdefault("resource_provider_id", path_params["id"]) parent.setdefault("server_id", path_params["id"]) return parent def _path_ends_with_item_param(path: str) -> bool: """True for item show paths (/x/{id}), false for nested collections (/x/{id}/ys).""" trimmed = path.rstrip("/") return bool(re.search(r"/\{[^{}/]+\}$", trimmed)) def _row_item(row: Any) -> dict[str, Any]: data = row["data"] if isinstance(data, str): data = json.loads(data) item = dict(data or {}) item.setdefault("id", str(row["id"])) item.setdefault("name", row["name"]) item.setdefault("status", row["status"]) if row["project_id"] is not None: item.setdefault("project_id", str(row["project_id"])) item.setdefault("tenant_id", str(row["project_id"])) item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ")) item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ")) return item def _paginate( items: list[dict[str, Any]], request: Request ) -> tuple[list[dict[str, Any]], dict[str, Any]]: try: limit = int(request.query_params.get("limit") or 0) except ValueError: limit = 0 marker = request.query_params.get("marker") start = 0 if marker: for i, item in enumerate(items): if str(item.get("id")) == marker or str(item.get("name")) == marker: start = i + 1 break page = items[start:] links: dict[str, Any] = {} if limit > 0: page = page[:limit] if start + limit < len(items): last = page[-1] if page else None if last: links["next"] = str(last.get("id") or last.get("name")) return page, links def _check_microversion(request: Request, op: OperationSpec, pack: ServicePack) -> None: if not op.microversion_min and not pack.max_microversion: return requested = getattr(request.state, "microversion", None) runtime = get_runtime() override = runtime.active_microversion(pack.name) chosen = requested or override or pack.default_microversion if not chosen: return maximum = pack.max_microversion or op.microversion_max minimum = op.microversion_min or pack.default_microversion if maximum and _mv_tuple(chosen) > _mv_tuple(maximum): raise OpenStackError( "VersionNotFound", f"Microversion {chosen} exceeds max {maximum}", status_code=406, ) if minimum and _mv_tuple(chosen) < _mv_tuple(minimum): raise OpenStackError( "VersionNotFound", f"Microversion {chosen} below min {minimum}", status_code=406, ) def _mv_tuple(value: str) -> tuple[int, ...]: parts = [] for piece in value.split("."): try: parts.append(int(piece)) except ValueError: parts.append(0) return tuple(parts) def _fixture_or_item( op: OperationSpec, item: dict[str, Any] | None, *, list_mode: bool = False ) -> Any: if op.response_fixture is not None: return op.response_fixture key = op.collection_key if list_mode and key: return {key: item if isinstance(item, list) else []} if op.item_key: return {op.item_key: item or {}} if key: return {_singular(key): item or {}} return item or {} async def _list_objects( conn: Connection, *, service: str, resource_type: str, project_id: Any, parent: dict[str, str] | None = None, ) -> list[dict[str, Any]]: if project_id is None: rows = await conn.fetch( """SELECT * FROM os_api_objects WHERE service=$1 AND resource_type=$2 ORDER BY created_at""", service, resource_type, ) else: rows = await conn.fetch( """SELECT * FROM os_api_objects WHERE service=$1 AND resource_type=$2 AND (project_id=$3 OR project_id IS NULL) ORDER BY created_at""", service, resource_type, project_id, ) items = [_row_item(r) for r in rows] if parent: filtered = [] for item in items: ok = True for pk, pv in parent.items(): if str(item.get(pk) or item.get("parent_id") or "") not in {pv, str(item.get(pk))}: # soft filter: keep if parent key absent if pk in item and str(item[pk]) != pv: ok = False break if ok: filtered.append(item) return filtered return items def _route_priority(op: OperationSpec) -> tuple[int, int, str]: """Static paths before templated ones so /detail is not captured by /{id}.""" path = op.path braces = path.count("{") detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1 return (braces, detail_bias, path) def build_schema_router(pack: ServicePack) -> APIRouter: router = APIRouter(tags=[f"Schema:{pack.name}"]) # Deduplicate by method+path so FastAPI does not register twice. seen: set[tuple[str, str]] = set() for op in sorted(pack.operations, key=_route_priority): key = (op.method, op.path) if key in seen: continue seen.add(key) _register_operation(router, pack, op) return router def _register_operation(router: APIRouter, pack: ServicePack, op: OperationSpec) -> None: path = _fastapi_path(op.path) name = f"schema-{pack.name}-{op.operation_id}" async def endpoint(request: Request) -> Response: return await _dispatch(request, pack, op) router.add_api_route( path, endpoint, methods=[op.method], name=name, include_in_schema=True, ) async def _resolve_ctx(request: Request, *, need_project: bool) -> TokenContext: database = request.app.state.database assert isinstance(database, AsyncpgDatabase) token = extract_token({k: v for k, v in request.headers.items()}) if not token: raise OpenStackError( "Unauthorized", "The request you have made requires authentication.", status_code=401, ) async with database.pool.acquire() as conn: ctx = await validate_token(conn, token) if need_project and ctx.project_id is None: raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401) return ctx def _has_id_param(path: str) -> bool: return "{id}" in path or "{name}" in path async def _dispatch(request: Request, pack: ServicePack, op: OperationSpec) -> Response: _check_microversion(request, op, pack) ctx: TokenContext | None = None if op.requires_auth: ctx = await _resolve_ctx(request, need_project=op.requires_project) database = request.app.state.database assert isinstance(database, AsyncpgDatabase) async with database.pool.acquire() as conn: path_params = dict(request.path_params) if op.kind == "action" or op.path.rstrip("/").endswith("/action"): return await _handle_action(request, conn, pack, op, ctx, path_params) if op.method == "GET": # Literal "/detail" list views must not be treated as item show. if op.kind == "detail" or str(path_params.get("id") or "").lower() == "detail": return await _handle_list(request, conn, pack, op, ctx, path_params) # Nested collection paths contain {id} but list children, not show the parent id. if op.kind == "collection" or ( op.collection_key and _has_id_param(op.path) and not _path_ends_with_item_param(op.path) ): return await _handle_list(request, conn, pack, op, ctx, path_params) if _path_ends_with_item_param(op.path) or op.kind == "item": return await _handle_show(request, conn, pack, op, ctx, path_params) return await _handle_list(request, conn, pack, op, ctx, path_params) if op.method == "POST": if _path_ends_with_item_param(op.path) and op.kind != "collection": return await _handle_action(request, conn, pack, op, ctx, path_params) if _has_id_param(op.path) and op.kind == "collection": return await _handle_create(request, conn, pack, op, ctx, path_params) if _has_id_param(op.path) and op.kind != "collection": return await _handle_action(request, conn, pack, op, ctx, path_params) return await _handle_create(request, conn, pack, op, ctx, path_params) if op.method in {"PUT", "PATCH"}: return await _handle_update(request, conn, pack, op, ctx, path_params) if op.method == "DELETE": return await _handle_delete(request, conn, pack, op, ctx, path_params) raise OpenStackError( "BadRequest", f"Unsupported operation {op.method} {op.path}", status_code=400 ) async def _handle_list( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: if op.response_fixture is not None: return JSONResponse(op.response_fixture) # Discovery docs live in PostgreSQL (seed_discovery_documents). from app.openstack.db_docs import require_doc if op.resource_type == "ping": return JSONResponse( await require_doc(conn, service=pack.name, resource_type="ping", name="default") ) if op.resource_type == "health": return JSONResponse( await require_doc(conn, service=pack.name, resource_type="health", name="default") ) if op.resource_type == "limit" and op.collection_key == "limits": doc = await require_doc(conn, service=pack.name, resource_type="limits", name="default") # Overlay live usage for cinder when volumes table is present. if pack.name == "cinder" and ctx and ctx.project_id is not None: used = await conn.fetchrow( """SELECT count(*)::int AS volumes, coalesce(sum(size), 0)::int AS gigabytes FROM os_volumes WHERE project_id=$1""", ctx.project_id, ) absolute = dict((doc.get("limits") or {}).get("absolute") or {}) if used: absolute["totalVolumesUsed"] = int(used["volumes"]) absolute["totalGigabytesUsed"] = int(used["gigabytes"]) return JSONResponse( { "limits": { "rate": (doc.get("limits") or {}).get("rate") or [], "absolute": absolute, } } ) return JSONResponse(doc) if op.resource_type == "version": return JSONResponse( await require_doc( conn, service=pack.name, resource_type="discovery_version", name="default" ) ) project_id = ctx.project_id if ctx else None items = await _list_objects( conn, service=pack.name, resource_type=op.resource_type, project_id=project_id, parent=_parent_scope(op.path, path_params) or None, ) # soft filter query params for qk, qv in request.query_params.items(): if qk in {"limit", "marker", "sort_key", "sort_dir", "fields"}: continue items = [i for i in items if str(i.get(qk, qv)) == qv or qk not in i] # Nested soft-filter may hide parent-scoped rows — re-read without parent filter # but still only from PostgreSQL (no synthetic templates). if not items and op.method == "GET" and op.kind in {"collection", "detail", "custom"}: parent = _parent_scope(op.path, path_params) or None if parent: items = await _list_objects( conn, service=pack.name, resource_type=op.resource_type, project_id=project_id, parent=None, ) page, links = _paginate(items, request) key = op.collection_key or "items" body: dict[str, Any] = {key: page} if links: body[f"{key}_links"] = links return JSONResponse(body, status_code=op.status_code) async def _handle_create( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: if ctx is None or ctx.project_id is None: raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401) try: payload = await request.json() except Exception: payload = {} key = op.item_key or (op.collection_key and _singular(op.collection_key)) or "resource" body = payload.get(key) if isinstance(payload, dict) else None if body is None and isinstance(payload, dict): body = payload.get(op.collection_key) or payload if not isinstance(body, dict): body = {"value": body} item_id = uuid4() name = str(body.get("name") or body.get("stack_name") or op.resource_type) status = str(body.get("status") or body.get("stack_status") or "ACTIVE") data = {**body, "id": str(item_id), "name": name, "status": status, **path_params} row = await conn.fetchrow( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) RETURNING *""", item_id, pack.name, op.resource_type, ctx.project_id, name, status, json.dumps(data), ) content = _fixture_or_item(op, _row_item(row)) return JSONResponse( content, status_code=op.create_status if op.method == "POST" else op.status_code ) async def _handle_show( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: item_id = ( path_params.get("id") or path_params.get("name") or next(iter(path_params.values()), None) ) if not item_id: # custom GET without id — fall back to list-like empty / fixture return await _handle_list(request, conn, pack, op, ctx, path_params) row = await conn.fetchrow( """SELECT * FROM os_api_objects WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) LIMIT 1""", pack.name, op.resource_type, item_id, ) if row is None: raise OpenStackError("NotFound", f"{op.resource_type} {item_id} not found", status_code=404) return JSONResponse(_fixture_or_item(op, _row_item(row)), status_code=op.status_code) async def _handle_update( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: if ctx is None or ctx.project_id is None: raise OpenStackError("Unauthorized", "Project-scoped token required", status_code=401) item_id = path_params.get("id") or next(iter(path_params.values()), None) if not item_id: raise OpenStackError("BadRequest", "Missing id", status_code=400) try: payload = await request.json() except Exception: payload = {} key = op.item_key or (op.collection_key and _singular(op.collection_key)) body = payload.get(key, payload) if isinstance(payload, dict) else {} if not isinstance(body, dict): body = {} row = await conn.fetchrow( """SELECT * FROM os_api_objects WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""", pack.name, op.resource_type, item_id, ctx.project_id, ) if row is None: # Lab upsert: pack PUT/PATCH against unknown ids still succeed (surface-complete). try: new_id = UUID(str(item_id)) except Exception: new_id = uuid4() data = { "id": str(new_id), "name": str(body.get("name") or op.resource_type), "status": "ACTIVE", **body, **path_params, } created = await conn.fetchrow( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, status=EXCLUDED.status, data=EXCLUDED.data, updated_at=now() RETURNING *""", new_id, pack.name, op.resource_type, ctx.project_id, str(data.get("name") or op.resource_type), str(data.get("status") or "ACTIVE"), json.dumps(data), ) if op.status_code == 204: return Response(status_code=204) return JSONResponse(_fixture_or_item(op, _row_item(created)), status_code=200) data = row["data"] if isinstance(data, str): data = json.loads(data) data = {**(data or {}), **body, "id": str(row["id"])} updated = await conn.fetchrow( """UPDATE os_api_objects SET name=$1, status=$2, data=$3::jsonb, updated_at=now() WHERE id=$4 RETURNING *""", str(data.get("name") or row["name"]), str(data.get("status") or row["status"]), json.dumps(data), row["id"], ) if op.status_code == 204: return Response(status_code=204) return JSONResponse(_fixture_or_item(op, _row_item(updated)), status_code=200) async def _handle_delete( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: item_id = path_params.get("id") or next(iter(path_params.values()), None) if not item_id: return Response(status_code=204) project_id = ctx.project_id if ctx else None if project_id is not None: await conn.execute( """DELETE FROM os_api_objects WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3) AND project_id=$4""", pack.name, op.resource_type, item_id, project_id, ) else: await conn.execute( """DELETE FROM os_api_objects WHERE service=$1 AND resource_type=$2 AND (id::text=$3 OR name=$3)""", pack.name, op.resource_type, item_id, ) return Response(status_code=op.status_code if op.status_code in {202, 204} else 204) async def _handle_action( request: Request, conn: Connection, pack: ServicePack, op: OperationSpec, ctx: TokenContext | None, path_params: dict[str, str], ) -> Response: try: payload = await request.json() except Exception: payload = {} action = op.action_name if op.action_name and op.action_name != "*" else None if action is None and isinstance(payload, dict) and payload: action = next(iter(payload.keys())) item_id = path_params.get("id") or path_params.get("server_id") # record action history for nova-like resources if ctx and item_id: await conn.execute( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) ON CONFLICT DO NOTHING""", uuid4(), pack.name, "instance_action" if pack.name == "nova" else f"{op.resource_type}_action", ctx.project_id, action or "action", "DONE", json.dumps( { "action": action, "instance_uuid": item_id, "request_id": request.headers.get("x-openstack-request-id") or str(uuid4()), "message": None, "start_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), } ), ) # update parent status for common power actions if action in {"os-start", "unshelve", "resume", "unpause", "unrescue"}: new_status = "ACTIVE" elif action in {"os-stop", "shelve", "shelveOffload"}: new_status = "SHUTOFF" elif action in {"pause"}: new_status = "PAUSED" elif action in {"suspend"}: new_status = "SUSPENDED" else: new_status = None if new_status and pack.name == "nova": await conn.execute( "UPDATE os_servers SET status=$1, updated_at=now() WHERE id::text=$2", new_status, item_id, ) await conn.execute( """UPDATE os_api_objects SET status=$1, data = jsonb_set(data, '{status}', to_jsonb($1::text)), updated_at=now() WHERE service=$2 AND resource_type='server' AND id::text=$3""", new_status, pack.name, item_id, ) if op.status_code == 204: return Response(status_code=204) if action in {"os-getConsoleOutput"} and item_id: from app.openstack.db_docs import require_doc row = await conn.fetchrow( """SELECT data FROM os_api_objects WHERE service='nova' AND resource_type='console_output' AND (name=$1 OR data->>'server_id'=$1) ORDER BY updated_at DESC LIMIT 1""", item_id, ) if row is not None: data = row["data"] if isinstance(data, str): data = json.loads(data) output = str((data or {}).get("output") or "") else: template = await require_doc( conn, service="nova", resource_type="console_output_template", name="default" ) output = str(template.get("output") or "") await conn.execute( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1,'nova','console_output',$2,$3,'ACTIVE',$4::jsonb)""", uuid4(), ctx.project_id if ctx else None, item_id, json.dumps({"server_id": item_id, "output": output}), ) return JSONResponse({"output": output}) if action in {"os-getVNCConsole", "remote-consoles"} or "console" in (action or "").lower(): from app.openstack.db_docs import require_doc console_type = "" console_url = "" if item_id: row = await conn.fetchrow( """SELECT data FROM os_api_objects WHERE service='nova' AND resource_type='console' AND (name=$1 OR data->>'server_id'=$1) ORDER BY updated_at DESC LIMIT 1""", item_id, ) if row is not None: data = row["data"] if isinstance(data, str): data = json.loads(data) console_type = str((data or {}).get("type") or "") console_url = str((data or {}).get("url") or "") else: template = await require_doc( conn, service="nova", resource_type="console_template", name="default" ) console_type = str(template.get("type") or "") console_url = str(template.get("url") or "").replace("__SERVER_ID__", item_id) await conn.execute( """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) VALUES($1,'nova','console',$2,$3,'ACTIVE',$4::jsonb)""", uuid4(), ctx.project_id if ctx else None, item_id, json.dumps({"server_id": item_id, "type": console_type, "url": console_url}), ) return JSONResponse({"console": {"type": console_type, "url": console_url}}) if action == "createImage" and item_id and ctx is not None: from app.openstack.db_docs import fetch_doc image_id = uuid4() body = payload.get("createImage") if isinstance(payload, dict) else None defaults = ( await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default") or {} ) name = "snapshot" if isinstance(body, dict): name = str(body.get("name") or name) await conn.execute( """INSERT INTO os_images(id, name, status, visibility, size, disk_format, container_format, owner_project_id) VALUES($1,$2,'active',$3,0,$4,$5,$6)""", image_id, name, defaults.get("visibility") or "private", defaults.get("disk_format") or "qcow2", defaults.get("container_format") or "bare", ctx.project_id, ) return JSONResponse({"image_id": str(image_id)}, status_code=202) return Response(status_code=op.status_code) def mount_schema_services( app: Any, *, series: str = "dalmatian", handlers: Any | None = None, ) -> int: """Register one FastAPI route per contract (method, path). Returns route count.""" from app.openstack.registry import HandlerRegistry, mount_contract_services runtime = ensure_loaded(series) registry = handlers if isinstance(handlers, HandlerRegistry) else HandlerRegistry() count = mount_contract_services( app, packs=runtime.packs, handlers=registry, dispatch_fn=_dispatch, ) app.state.openstack_contract = runtime app.state.openstack_handlers = registry return count def remount_schema_services(app: Any, series: str) -> dict[str, Any]: """Reload pack metadata and rebuild per-path contract routes on the app router.""" from app.openstack.registry import HandlerRegistry, mount_contract_services runtime = get_runtime() summary = runtime.reload(series) handlers = getattr(app.state, "openstack_handlers", None) if not isinstance(handlers, HandlerRegistry): handlers = HandlerRegistry() app.state.openstack_handlers = handlers count = mount_contract_services( app, packs=runtime.packs, handlers=handlers, dispatch_fn=_dispatch, ) app.state.openstack_contract = runtime app.state.openstack_schema_ops = count summary = {**summary, "routes_mounted": count} return summary