290 lines
9.9 KiB
Python
290 lines
9.9 KiB
Python
"""Generic OpenStack collection/item CRUD backed by os_api_objects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from asyncpg import Connection
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.openstack.auth import TokenContext
|
|
from app.openstack.deps import get_conn, require_project_token, require_token
|
|
from app.openstack.errors import OpenStackError
|
|
from app.openstack.singular import singular as _singular
|
|
from app.openstack.surface import SERVICES, ServiceSpec
|
|
|
|
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
|
|
|
|
|
def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {collection_key: items}
|
|
|
|
|
|
def _wrap_item(collection_key: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
return {_singular(collection_key): item}
|
|
|
|
|
|
def _row_to_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 _has_path_params(path: str) -> bool:
|
|
return bool(_PATH_PARAM.search(path))
|
|
|
|
|
|
def build_generic_router(spec: ServiceSpec) -> APIRouter:
|
|
router = APIRouter(tags=[spec.name.title()])
|
|
|
|
version_path = (spec.version_path or "").rstrip("/")
|
|
if version_path and version_path != "/":
|
|
service_name = spec.name
|
|
|
|
async def version_discovery(
|
|
conn: Connection = Depends(get_conn),
|
|
) -> dict[str, object]:
|
|
from app.openstack.db_docs import require_doc
|
|
|
|
return await require_doc(
|
|
conn, service=service_name, resource_type="discovery_version", name="default"
|
|
)
|
|
|
|
router.add_api_route(
|
|
version_path,
|
|
version_discovery,
|
|
methods=["GET"],
|
|
name=f"gen-{spec.name}-version",
|
|
)
|
|
router.add_api_route(
|
|
version_path + "/",
|
|
version_discovery,
|
|
methods=["GET"],
|
|
name=f"gen-{spec.name}-version-slash",
|
|
)
|
|
|
|
for resource_type, collection_path, collection_key in spec.resources:
|
|
if collection_path in {"", "/"} or _has_path_params(collection_path):
|
|
# Nested templates / bare roots need specialized routers.
|
|
continue
|
|
_register_collection(
|
|
router,
|
|
spec=spec,
|
|
resource_type=resource_type,
|
|
collection_path=collection_path,
|
|
collection_key=collection_key,
|
|
)
|
|
return router
|
|
|
|
|
|
def _register_collection(
|
|
router: APIRouter,
|
|
*,
|
|
spec: ServiceSpec,
|
|
resource_type: str,
|
|
collection_path: str,
|
|
collection_key: str,
|
|
) -> None:
|
|
item_path = f"{collection_path.rstrip('/')}/{{item_id}}"
|
|
|
|
async def list_items(
|
|
conn: Connection = Depends(get_conn),
|
|
ctx: TokenContext = Depends(require_token),
|
|
) -> dict[str, Any]:
|
|
if ctx.project_id is None:
|
|
rows = await conn.fetch(
|
|
"""SELECT * FROM os_api_objects
|
|
WHERE service = $1 AND resource_type = $2
|
|
ORDER BY created_at""",
|
|
spec.name,
|
|
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""",
|
|
spec.name,
|
|
resource_type,
|
|
ctx.project_id,
|
|
)
|
|
return _wrap_list(collection_key, [_row_to_item(r) for r in rows])
|
|
|
|
async def create_item(
|
|
request: Request,
|
|
conn: Connection = Depends(get_conn),
|
|
ctx: TokenContext = Depends(require_project_token),
|
|
) -> JSONResponse:
|
|
payload = await request.json()
|
|
body = payload.get(_singular(collection_key)) or payload.get(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 resource_type)
|
|
status = str(body.get("status") or body.get("stack_status") or "ACTIVE")
|
|
data = {**body, "id": str(item_id), "name": name, "status": status}
|
|
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,
|
|
spec.name,
|
|
resource_type,
|
|
ctx.project_id,
|
|
name,
|
|
status,
|
|
json.dumps(data),
|
|
)
|
|
return JSONResponse(status_code=201, content=_wrap_item(collection_key, _row_to_item(row)))
|
|
|
|
async def show_item(
|
|
item_id: str,
|
|
conn: Connection = Depends(get_conn),
|
|
ctx: TokenContext = Depends(require_token),
|
|
) -> dict[str, Any]:
|
|
row = await conn.fetchrow(
|
|
"""SELECT * FROM os_api_objects
|
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3""",
|
|
spec.name,
|
|
resource_type,
|
|
item_id,
|
|
)
|
|
if row is None:
|
|
# also allow name lookup
|
|
row = await conn.fetchrow(
|
|
"""SELECT * FROM os_api_objects
|
|
WHERE service = $1 AND resource_type = $2 AND name = $3
|
|
LIMIT 1""",
|
|
spec.name,
|
|
resource_type,
|
|
item_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError(
|
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
|
)
|
|
if (
|
|
ctx.project_id is not None
|
|
and row["project_id"] is not None
|
|
and row["project_id"] != ctx.project_id
|
|
and not ctx.is_admin
|
|
):
|
|
raise OpenStackError(
|
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
|
)
|
|
return _wrap_item(collection_key, _row_to_item(row))
|
|
|
|
async def update_item(
|
|
item_id: str,
|
|
request: Request,
|
|
conn: Connection = Depends(get_conn),
|
|
ctx: TokenContext = Depends(require_project_token),
|
|
) -> dict[str, Any]:
|
|
payload = await request.json()
|
|
body = payload.get(_singular(collection_key), payload)
|
|
if not isinstance(body, dict):
|
|
raise OpenStackError("BadRequest", "JSON object required", status_code=400)
|
|
row = await conn.fetchrow(
|
|
"""SELECT * FROM os_api_objects
|
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
|
|
spec.name,
|
|
resource_type,
|
|
item_id,
|
|
ctx.project_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError(
|
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
|
)
|
|
data = row["data"]
|
|
if isinstance(data, str):
|
|
data = json.loads(data)
|
|
data = {**(data or {}), **body, "id": str(row["id"])}
|
|
name = str(data.get("name") or row["name"])
|
|
status = str(data.get("status") or row["status"])
|
|
updated = await conn.fetchrow(
|
|
"""UPDATE os_api_objects
|
|
SET name = $1, status = $2, data = $3::jsonb, updated_at = now()
|
|
WHERE id = $4
|
|
RETURNING *""",
|
|
name,
|
|
status,
|
|
json.dumps(data),
|
|
row["id"],
|
|
)
|
|
return _wrap_item(collection_key, _row_to_item(updated))
|
|
|
|
async def delete_item(
|
|
item_id: str,
|
|
conn: Connection = Depends(get_conn),
|
|
ctx: TokenContext = Depends(require_project_token),
|
|
) -> Response:
|
|
result = await conn.execute(
|
|
"""DELETE FROM os_api_objects
|
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
|
|
spec.name,
|
|
resource_type,
|
|
item_id,
|
|
ctx.project_id,
|
|
)
|
|
if result.endswith("0"):
|
|
raise OpenStackError(
|
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
|
)
|
|
return Response(status_code=204)
|
|
|
|
# Bind with defaults to capture loop variables.
|
|
router.add_api_route(
|
|
collection_path, list_items, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-list"
|
|
)
|
|
router.add_api_route(
|
|
collection_path,
|
|
create_item,
|
|
methods=["POST"],
|
|
name=f"gen-{spec.name}-{resource_type}-create",
|
|
)
|
|
router.add_api_route(
|
|
item_path, show_item, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-show"
|
|
)
|
|
router.add_api_route(
|
|
item_path,
|
|
update_item,
|
|
methods=["PUT", "PATCH"],
|
|
name=f"gen-{spec.name}-{resource_type}-update",
|
|
)
|
|
router.add_api_route(
|
|
item_path, delete_item, methods=["DELETE"], name=f"gen-{spec.name}-{resource_type}-delete"
|
|
)
|
|
|
|
|
|
def mount_generic_services(app: Any, *, skip: set[str] | None = None) -> int:
|
|
"""Mount generic routers under /_os/<service> for every service not in skip."""
|
|
|
|
skipped = skip or set()
|
|
count = 0
|
|
for spec in SERVICES:
|
|
if spec.name in skipped:
|
|
continue
|
|
app.include_router(build_generic_router(spec), prefix=f"/_os/{spec.name}")
|
|
count += 1 + sum(
|
|
1
|
|
for _, path, _ in spec.resources
|
|
if path not in {"", "/"} and not _has_path_params(path)
|
|
)
|
|
return count
|