6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
280 lines
9.5 KiB
Python
280 lines
9.5 KiB
Python
"""Ironic Bare Metal API v1."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Annotated, Any
|
|
from uuid import uuid4
|
|
|
|
from asyncpg import Connection
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
|
|
from app.openstack.auth import TokenContext
|
|
from app.openstack.deps import get_conn, require_token
|
|
from app.openstack.errors import OpenStackError
|
|
|
|
router = APIRouter(tags=["Ironic"])
|
|
|
|
|
|
def _node(row: Any) -> dict[str, Any]:
|
|
props = row["properties"]
|
|
if isinstance(props, str):
|
|
props = json.loads(props)
|
|
return {
|
|
"id": str(row["id"]),
|
|
"uuid": str(row["id"]),
|
|
"name": row["name"],
|
|
"driver": row["driver"],
|
|
"provision_state": row["provision_state"],
|
|
"power_state": row["power_state"],
|
|
"resource_class": row["resource_class"],
|
|
"properties": props or {},
|
|
"driver_info": row["driver_info"]
|
|
if not isinstance(row["driver_info"], str)
|
|
else json.loads(row["driver_info"]),
|
|
"ports": row["ports"] if not isinstance(row["ports"], str) else json.loads(row["ports"]),
|
|
"maintenance": False,
|
|
"links": [{"rel": "self", "href": f"/v1/nodes/{row['id']}"}],
|
|
}
|
|
|
|
|
|
@router.get("/v1")
|
|
@router.get("/v1/")
|
|
async def ironic_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
|
|
from app.openstack.db_docs import require_doc
|
|
|
|
return await require_doc(
|
|
conn, service="ironic", resource_type="discovery_version", name="default"
|
|
)
|
|
|
|
|
|
@router.get("/v1/nodes")
|
|
async def list_nodes(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from app.openstack.paging import paginate_rows
|
|
|
|
rows = list(await conn.fetch("SELECT * FROM os_nodes ORDER BY name, id"))
|
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
|
body: dict[str, object] = {"nodes": [_node(r) for r in page]}
|
|
if links:
|
|
body["nodes_links"] = links
|
|
return body
|
|
|
|
|
|
@router.post("/v1/nodes", status_code=201)
|
|
async def create_node(
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
from app.openstack.db_docs import fetch_doc
|
|
|
|
payload = await request.json()
|
|
defaults = (
|
|
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
|
|
)
|
|
props = (
|
|
payload.get("properties")
|
|
if isinstance(payload.get("properties"), dict)
|
|
else defaults.get("properties")
|
|
)
|
|
if not isinstance(props, dict):
|
|
props = {}
|
|
row = await conn.fetchrow(
|
|
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports)
|
|
VALUES($1,$2,$3,'available','power off',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
|
|
uuid4(),
|
|
payload.get("name") or f"node-{uuid4().hex[:8]}",
|
|
payload.get("driver") or defaults.get("driver"),
|
|
payload.get("resource_class") or defaults.get("resource_class"),
|
|
json.dumps(props),
|
|
json.dumps(payload.get("driver_info") or {}),
|
|
)
|
|
return _node(row)
|
|
|
|
|
|
@router.get("/v1/nodes/{node_id}")
|
|
async def show_node(
|
|
node_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
row = await conn.fetchrow("SELECT * FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
|
return _node(row)
|
|
|
|
|
|
async def _update_node(
|
|
resource_id: str,
|
|
request: Request,
|
|
conn: Connection,
|
|
) -> dict[str, Any]:
|
|
payload = await request.json()
|
|
row = await conn.fetchrow(
|
|
"""UPDATE os_nodes
|
|
SET name = COALESCE($1, name), updated_at = now()
|
|
WHERE id::text = $2 OR name = $2
|
|
RETURNING *""",
|
|
payload.get("name"),
|
|
resource_id,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
|
return _node(row)
|
|
|
|
|
|
@router.put("/v1/nodes/{node_id}")
|
|
@router.patch("/v1/nodes/{node_id}")
|
|
async def update_node(
|
|
node_id: str,
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
return await _update_node(node_id, request, conn)
|
|
|
|
|
|
@router.put("/v1/nodes/{id}")
|
|
@router.patch("/v1/nodes/{id}")
|
|
async def update_node_by_id(
|
|
id: str,
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
return await _update_node(id, request, conn)
|
|
|
|
|
|
@router.delete("/v1/nodes/{node_id}", status_code=204)
|
|
async def delete_node(
|
|
node_id: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> Response:
|
|
result = await conn.execute("DELETE FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
|
if result.endswith("0"):
|
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.put("/v1/nodes/{node_id}/states/provision")
|
|
@router.put("/v1/nodes/{node_id}/states/power")
|
|
async def node_state(
|
|
node_id: str,
|
|
request: Request,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> Response:
|
|
from app.openstack.db_docs import fetch_doc
|
|
|
|
payload = await request.json()
|
|
target = payload.get("target") or payload.get("state")
|
|
defaults = (
|
|
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
|
|
)
|
|
row = await conn.fetchrow("SELECT id FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
|
if "power" in request.url.path:
|
|
await conn.execute(
|
|
"UPDATE os_nodes SET power_state=$1, updated_at=now() WHERE id=$2",
|
|
target or defaults.get("power_state"),
|
|
row["id"],
|
|
)
|
|
else:
|
|
await conn.execute(
|
|
"UPDATE os_nodes SET provision_state=$1, updated_at=now() WHERE id=$2",
|
|
target or defaults.get("provision_state"),
|
|
row["id"],
|
|
)
|
|
return Response(status_code=202)
|
|
|
|
|
|
@router.get("/v1/drivers")
|
|
async def list_drivers(
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
import json as _json
|
|
|
|
rows = await conn.fetch(
|
|
"""SELECT id, name, data FROM os_api_objects
|
|
WHERE service='ironic' AND resource_type='driver'
|
|
ORDER BY created_at NULLS LAST, name"""
|
|
)
|
|
drivers: list[dict[str, object]] = []
|
|
for row in rows:
|
|
data = row["data"] if isinstance(row["data"], dict) else _json.loads(row["data"] or "{}")
|
|
drivers.append(
|
|
{
|
|
"name": row["name"] or data.get("name"),
|
|
"hosts": list(data.get("hosts") or []),
|
|
"type": data.get("type"),
|
|
}
|
|
)
|
|
return {"drivers": drivers}
|
|
|
|
|
|
@router.get("/v1/nodes/{node_ident}/states")
|
|
async def node_states(
|
|
node_ident: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
row = await conn.fetchrow(
|
|
"SELECT power_state, provision_state FROM os_nodes WHERE id::text=$1 OR name=$1",
|
|
node_ident,
|
|
)
|
|
if row is None:
|
|
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
|
|
return {
|
|
"power": row["power_state"],
|
|
"provision": row["provision_state"],
|
|
"raid": None,
|
|
"console": False,
|
|
"boot_mode": None,
|
|
}
|
|
|
|
|
|
@router.get("/v1/nodes/{node_ident}/vendor_passthru")
|
|
async def node_vendor_passthru(
|
|
node_ident: str,
|
|
conn: Annotated[Connection, Depends(get_conn)],
|
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
|
) -> dict[str, object]:
|
|
exists = await conn.fetchval(
|
|
"SELECT 1 FROM os_nodes WHERE id::text=$1 OR name=$1",
|
|
node_ident,
|
|
)
|
|
if not exists:
|
|
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
|
|
row = await conn.fetchrow(
|
|
"""SELECT data FROM os_api_objects
|
|
WHERE service='ironic' AND resource_type='vendor_passthru'
|
|
AND (name=$1 OR data->>'node_id'=$1 OR data->>'node_uuid'=$1)
|
|
ORDER BY updated_at DESC LIMIT 1""",
|
|
node_ident,
|
|
)
|
|
if row is not None:
|
|
data = row["data"]
|
|
if isinstance(data, str):
|
|
data = json.loads(data)
|
|
methods = (data or {}).get("methods") or (data or {}).get("vendor_passthru") or data
|
|
if isinstance(methods, dict) and methods:
|
|
return {"vendor_passthru": methods}
|
|
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
|
|
# Persist empty methods doc so subsequent GETs are DB-backed.
|
|
await conn.execute(
|
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
|
VALUES($1,'ironic','vendor_passthru',NULL,$2,'ACTIVE',$3::jsonb)
|
|
ON CONFLICT (id) DO NOTHING""",
|
|
uuid4(),
|
|
node_ident,
|
|
json.dumps({"node_id": node_ident, "methods": {}}),
|
|
)
|
|
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
|