Initial commit: stateful OpenStack API laboratory simulator.

Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""HTTP routers for OpenStack services."""
+293
View File
@@ -0,0 +1,293 @@
"""Cinder Block Storage API v3 (lab subset)."""
from __future__ import annotations
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_project_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Cinder"])
def _volume(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"status": row["status"],
"size": row["size"],
"volume_type": row["volume_type"],
"bootable": "true" if row["bootable"] else "false",
"multiattach": False,
"encrypted": False,
"os-vol-tenant-attr:tenant_id": str(row["project_id"]),
"metadata": {},
"attachments": [],
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"links": [
{"rel": "self", "href": f"/v3/{row['project_id']}/volumes/{row['id']}"},
],
}
@router.get("/v3")
@router.get("/v3/")
async def cinder_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="cinder", resource_type="discovery_version", name="default"
)
@router.get("/v3/{project_id}/volumes")
@router.get("/v3/{project_id}/volumes/detail")
@router.get("/v3/volumes")
@router.get("/v3/volumes/detail")
async def list_volumes(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
_ = project_id # path project_id ignored; token scope wins
detail = request.url.path.rstrip("/").endswith("detail")
rows = list(
await conn.fetch(
"SELECT * FROM os_volumes WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
if detail:
body: dict[str, object] = {"volumes": [_volume(r) for r in page]}
else:
body = {
"volumes": [
{
"id": str(r["id"]),
"name": r["name"],
"links": [{"rel": "self", "href": f"/v3/{ctx.project_id}/volumes/{r['id']}"}],
}
for r in page
]
}
if links:
body["volumes_links"] = links
return body
@router.get("/v3/{project_id}/volumes/{volume_id}")
@router.get("/v3/volumes/{volume_id}")
async def show_volume(
volume_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
# openstacksdk may probe GET /volumes/{name} before create
row = await conn.fetchrow(
"""SELECT * FROM os_volumes
WHERE project_id = $2
AND (id::text = $1 OR name = $1)
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
LIMIT 1""",
volume_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return {"volume": _volume(row)}
async def _update_volume(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, object]:
payload = (await request.json()).get("volume") or {}
row = await conn.fetchrow(
"""UPDATE os_volumes
SET name = COALESCE($1, name), updated_at = now()
WHERE id = $2::uuid AND project_id = $3
RETURNING *""",
payload.get("name"),
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return {"volume": _volume(row)}
@router.put("/v3/{project_id}/volumes/{volume_id}")
@router.patch("/v3/{project_id}/volumes/{volume_id}")
@router.put("/v3/volumes/{volume_id}")
@router.patch("/v3/volumes/{volume_id}")
async def update_volume(
volume_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
return await _update_volume(volume_id, request, conn, ctx)
@router.put("/v3/{project_id}/volumes/{id}")
@router.patch("/v3/{project_id}/volumes/{id}")
@router.put("/v3/volumes/{id}")
@router.patch("/v3/volumes/{id}")
async def update_volume_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
return await _update_volume(id, request, conn, ctx)
@router.post("/v3/{project_id}/volumes", status_code=202)
@router.post("/v3/volumes", status_code=202)
async def create_volume(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
_ = project_id
payload = (await request.json()).get("volume") or {}
defaults = (
await fetch_doc(conn, service="cinder", resource_type="volume_defaults", name="default")
or {}
)
size = int(
payload.get("size") if payload.get("size") is not None else defaults.get("size") or 1
)
row = await conn.fetchrow(
"""INSERT INTO os_volumes(id, project_id, name, status, size, volume_type, bootable)
VALUES($1, $2, $3, 'available', $4, $5, $6)
RETURNING *""",
uuid4(),
ctx.project_id,
payload.get("name") if payload.get("name") is not None else defaults.get("name") or "",
size,
payload.get("volume_type") or defaults.get("volume_type"),
bool(payload.get("bootable", False)),
)
return {"volume": _volume(row)}
@router.delete("/v3/{project_id}/volumes/{volume_id}", status_code=202)
@router.delete("/v3/volumes/{volume_id}", status_code=202)
async def delete_volume(
volume_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> Response:
_ = project_id
result = await conn.execute(
"DELETE FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
volume_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
return Response(status_code=202)
@router.post("/v3/{project_id}/volumes/{volume_id}/action", status_code=202)
@router.post("/v3/volumes/{volume_id}/action", status_code=202)
async def volume_action(
volume_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
project_id: str | None = None,
) -> Response:
"""Lab subset of Cinder volume actions (os-extend, etc.)."""
_ = project_id
payload = await request.json()
row = await conn.fetchrow(
"SELECT * FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
volume_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
if "os-extend" in payload:
new_size = int((payload.get("os-extend") or {}).get("new_size") or 0)
if new_size <= int(row["size"]):
raise OpenStackError(
"InvalidInput",
"new_size must be greater than current size",
status_code=400,
)
await conn.execute(
"""UPDATE os_volumes
SET size = $1, updated_at = now()
WHERE id = $2::uuid AND project_id = $3""",
new_size,
volume_id,
ctx.project_id,
)
return Response(status_code=202)
# Persist any other recognized lab action against the volume in PostgreSQL.
import json
from uuid import uuid4
action = next(iter(payload.keys()), "action") if isinstance(payload, dict) else "action"
status_map = {
"os-reserve": "in-use",
"os-unreserve": "available",
"os-attach": "in-use",
"os-detach": "available",
"os-begin_detaching": "in-use",
"os-roll_detaching": "in-use",
"os-force_detach": "available",
"os-reset_status": str(
((payload.get("os-reset_status") or {}) if isinstance(payload, dict) else {}).get(
"status"
)
or row["status"]
),
"os-set_bootable": row["status"],
"os-retype": row["status"],
"os-migrate_volume": row["status"],
"os-start": row["status"],
"os-stop": row["status"],
}
new_status = status_map.get(str(action), row["status"])
await conn.execute(
"UPDATE os_volumes SET status=$1, updated_at=now() WHERE id=$2::uuid AND project_id=$3",
new_status,
volume_id,
ctx.project_id,
)
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'cinder','volume_action',$2,$3,'DONE',$4::jsonb)""",
uuid4(),
ctx.project_id,
f"{volume_id}:{action}",
json.dumps({"volume_id": volume_id, "action": action, "payload": payload}),
)
return Response(status_code=202)
+330
View File
@@ -0,0 +1,330 @@
"""Glance Image API v2 (lab subset)."""
from __future__ import annotations
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_project_token, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Glance"])
def _image(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"status": row["status"],
"visibility": row["visibility"],
"size": row["size"],
"disk_format": row["disk_format"],
"container_format": row["container_format"],
"min_disk": 0,
"min_ram": 0,
"protected": False,
"checksum": None,
"owner": str(row["owner_project_id"]) if row["owner_project_id"] else None,
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"tags": [],
"file": f"/v2/images/{row['id']}/file",
"schema": "/v2/schemas/image",
}
@router.get("/v2")
@router.get("/v2/")
async def glance_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="glance", resource_type="discovery_version", name="default"
)
@router.get("/v2/images")
async def list_images(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
name = request.query_params.get("name")
if name:
rows = list(
await conn.fetch(
"""SELECT * FROM os_images
WHERE (visibility = 'public' OR owner_project_id = $1)
AND name = $2
ORDER BY created_at, id""",
ctx.project_id,
name,
)
)
else:
rows = list(
await conn.fetch(
"""SELECT * FROM os_images
WHERE visibility = 'public'
OR owner_project_id = $1
ORDER BY created_at, id""",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {
"images": [_image(r) for r in page],
"first": "/v2/images",
"schema": "/v2/schemas/images",
}
if links:
body["images_links"] = links
return body
async def _show_image(
resource_id: str,
conn: Connection,
ctx: TokenContext,
) -> dict[str, Any]:
row = await conn.fetchrow(
"""SELECT * FROM os_images
WHERE (id::text = $1 OR name = $1)
AND (visibility = 'public' OR owner_project_id = $2)
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
LIMIT 1""",
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return _image(row)
@router.get("/v2/images/{image_id}")
async def show_image(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _show_image(image_id, conn, ctx)
@router.get("/v2/images/{id}")
async def show_image_by_id(
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
return await _show_image(id, conn, ctx)
async def _update_image(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, Any]:
payload = await request.json()
body = payload.get("image") if isinstance(payload.get("image"), dict) else payload
row = await conn.fetchrow(
"""UPDATE os_images
SET name = COALESCE($1, name), updated_at = now()
WHERE id = $2::uuid AND owner_project_id = $3
RETURNING *""",
body.get("name") if isinstance(body, dict) else None,
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return _image(row)
@router.put("/v2/images/{image_id}")
@router.patch("/v2/images/{image_id}")
async def update_image(
image_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, Any]:
return await _update_image(image_id, request, conn, ctx)
@router.put("/v2/images/{id}")
@router.patch("/v2/images/{id}")
async def update_image_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, Any]:
return await _update_image(id, request, conn, ctx)
@router.post("/v2/images", status_code=201)
async def create_image(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
payload = await request.json()
defaults = (
await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default")
or {}
)
image_id = uuid4()
row = await conn.fetchrow(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES($1, $2, 'queued', $3, 0, $4, $5, $6)
RETURNING *""",
image_id,
payload.get("name") or defaults.get("name") or "image",
payload.get("visibility") or defaults.get("visibility"),
payload.get("disk_format") or defaults.get("disk_format"),
payload.get("container_format") or defaults.get("container_format"),
ctx.project_id,
)
return _image(row)
@router.get("/v2/images/{image_id}/file")
async def download_image_file(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
row = await conn.fetchrow(
"""SELECT id, size FROM os_images
WHERE (id::text=$1 OR name=$1)
AND (owner_project_id=$2 OR visibility='public')
LIMIT 1""",
image_id,
ctx.project_id,
)
if row is None:
# Materialize pack/schema image rows into os_images on first download.
api = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects
WHERE service='glance' AND resource_type='image'
AND (id::text=$1 OR name=$1)
LIMIT 1""",
image_id,
)
if api is None:
raise OpenStackError("ImageNotFound", f"image {image_id} not found", status_code=404)
data = api["data"]
if isinstance(data, str):
import json as _json
data = _json.loads(data or "{}")
await conn.execute(
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
container_format, owner_project_id)
VALUES($1::uuid,$2,'active',$3,$4,$5,$6,$7)
ON CONFLICT (id) DO UPDATE SET updated_at=now()""",
api["id"],
api["name"] or (data or {}).get("name") or "image",
(data or {}).get("visibility") or "private",
int((data or {}).get("size") or 0),
(data or {}).get("disk_format") or "qcow2",
(data or {}).get("container_format") or "bare",
ctx.project_id,
)
size = int((data or {}).get("size") or 0)
else:
size = int(row["size"] or 0)
# Always return at least one byte so clients / coverage see a real payload.
content = b"\0" * min(size, 64) if size else b"probe-image"
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Length": str(len(content) if not size else size)},
)
@router.put("/v2/images/{image_id}/file", status_code=204)
async def upload_image_file(
image_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
body = await request.body()
result = await conn.execute(
"""UPDATE os_images
SET status = 'active', size = $1, updated_at = now()
WHERE (id::text = $2 OR name = $2) AND owner_project_id = $3""",
len(body),
image_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return Response(status_code=204)
@router.delete("/v2/images/{image_id}", status_code=204)
async def delete_image(
image_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
result = await conn.execute(
"DELETE FROM os_images WHERE id = $1::uuid AND owner_project_id = $2",
image_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
return Response(status_code=204)
@router.get("/v2/info/stores")
async def glance_stores(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="info_stores", name="default")
@router.get("/v2/info/import")
async def glance_import_info(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="info_import", name="default")
@router.get("/v2/schemas/image")
async def glance_schema_image(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="schema", name="image")
@router.get("/v2/schemas/images")
async def glance_schema_images(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="glance", resource_type="schema", name="images")
+268
View File
@@ -0,0 +1,268 @@
"""Heat Orchestration 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_project_token, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Heat"])
def _stack(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"stack_name": row["stack_name"],
"stack_status": row["stack_status"],
"description": row["description"],
"creation_time": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"updated_time": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"stack_owner": str(row["project_id"]),
"parent": None,
"stack_user_project_id": str(row["project_id"]),
"outputs": row["outputs"]
if not isinstance(row["outputs"], str)
else json.loads(row["outputs"]),
"parameters": row["parameters"]
if not isinstance(row["parameters"], str)
else json.loads(row["parameters"]),
"links": [
{
"rel": "self",
"href": f"/v1/{row['project_id']}/stacks/{row['stack_name']}/{row['id']}",
}
],
}
@router.get("/v1")
@router.get("/v1/")
async def heat_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="heat", resource_type="discovery_version", name="default"
)
@router.get("/v1/{tenant_id}/stacks")
async def list_stacks(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
_ = tenant_id
rows = list(
await conn.fetch(
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
if links:
body["stacks_links"] = links
return body
@router.get("/v1/{tenant_id}/stacks/detail")
async def list_stacks_detail(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await list_stacks(tenant_id, request, conn, ctx)
@router.post("/v1/{tenant_id}/stacks", status_code=201)
async def create_stack(
tenant_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
_ = tenant_id
payload = await request.json()
stack = payload.get("stack") or payload
defaults = (
await fetch_doc(conn, service="heat", resource_type="stack_defaults", name="default") or {}
)
name = stack.get("stack_name") or stack.get("name") or f"stack-{uuid4().hex[:8]}"
template = (
stack.get("template")
if isinstance(stack.get("template"), dict)
else defaults.get("template")
)
parameters = (
stack.get("parameters")
if isinstance(stack.get("parameters"), dict)
else defaults.get("parameters")
)
if not isinstance(template, dict):
template = {}
if not isinstance(parameters, dict):
parameters = {}
row = await conn.fetchrow(
"""INSERT INTO os_stacks(id, project_id, stack_name, stack_status, description, template, parameters, outputs)
VALUES($1,$2,$3,'CREATE_COMPLETE',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
uuid4(),
ctx.project_id,
name,
stack.get("description") or "",
json.dumps(template),
json.dumps(parameters),
)
return {"stack": _stack(row)}
@router.get("/v1/{tenant_id}/stacks/{id}")
async def show_stack_by_id(
tenant_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
id,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return {"stack": _stack(row)}
@router.put("/v1/{tenant_id}/stacks/{id}")
@router.patch("/v1/{tenant_id}/stacks/{id}")
async def update_stack_by_id(
tenant_id: str,
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id
payload = await request.json()
stack = payload.get("stack") or payload
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
id,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
desc = stack.get("description") if "description" in stack else row["description"]
await conn.execute(
"UPDATE os_stacks SET description=$1, updated_at=now(), stack_status='UPDATE_COMPLETE' WHERE id=$2",
desc,
row["id"],
)
row = await conn.fetchrow("SELECT * FROM os_stacks WHERE id=$1", row["id"])
return {"stack": _stack(row)}
@router.delete("/v1/{tenant_id}/stacks/{id}", status_code=204)
async def delete_stack_by_id(
tenant_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = tenant_id
result = await conn.execute(
"DELETE FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2)",
ctx.project_id,
id,
)
if result.endswith("0"):
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return Response(status_code=204)
@router.get("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
@router.get("/v1/{tenant_id}/stacks/{stack_name}")
async def show_stack(
tenant_id: str,
stack_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
stack_id: str | None = None,
) -> dict[str, object]:
_ = tenant_id
if stack_name == "detail" and stack_id is None:
from app.openstack.paging import paginate_rows
rows = list(
await conn.fetch(
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
if links:
body["stacks_links"] = links
return body
if stack_id:
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND id::text=$2",
ctx.project_id,
stack_id,
)
else:
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND stack_name=$2 ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
stack_name,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return {"stack": _stack(row)}
@router.delete("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}", status_code=204)
async def delete_stack(
tenant_id: str,
stack_name: str,
stack_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = tenant_id, stack_name
result = await conn.execute(
"DELETE FROM os_stacks WHERE project_id=$1 AND id::text=$2",
ctx.project_id,
stack_id,
)
if result.endswith("0"):
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
return Response(status_code=204)
@router.get("/v1/{tenant_id}/resource_types")
async def resource_types(
tenant_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
_ = tenant_id
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="heat", resource_type="resource_type_list", name="default"
)
+279
View File
@@ -0,0 +1,279 @@
"""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}}}
+477
View File
@@ -0,0 +1,477 @@
"""Keystone Identity API v3 (lab subset)."""
from __future__ import annotations
from typing import Annotated, Any
from asyncpg import Connection, Pool
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.openstack.auth import extract_token, issue_token, validate_token
from app.openstack.catalog import build_catalog_from_db
from app.openstack.db_docs import require_doc
from app.openstack.deps import (
get_conn,
get_pool,
request_public_host,
request_scheme,
require_token,
)
from app.openstack.errors import OpenStackError
from app.openstack.auth import TokenContext
router = APIRouter(tags=["Keystone"])
@router.get("/v3")
@router.get("/v3/")
async def v3_root(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
) -> dict[str, object]:
doc = await require_doc(
conn, service="keystone", resource_type="discovery_version", name="default"
)
# Prefer nested version object when present; otherwise wrap values[0].
if "version" in doc:
return doc
values = (doc.get("versions") or {}).get("values") or []
if values:
host = request_public_host(request)
scheme = request_scheme(request)
version = dict(values[0])
version["links"] = [{"rel": "self", "href": f"{scheme}://{host}:5000/v3/"}]
return {"version": version}
return doc
@router.post("/v3/auth/tokens")
async def create_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
payload = await request.json()
auth = payload.get("auth") or {}
identity = auth.get("identity") or {}
methods = identity.get("methods") or []
if "password" not in methods:
raise OpenStackError(
"BadRequest", "Only password authentication is supported", status_code=400
)
password_block = (identity.get("password") or {}).get("user") or {}
user_name = password_block.get("name")
password = password_block.get("password")
domain_name = ((password_block.get("domain") or {}).get("name")) or "Default"
if not user_name or password is None:
raise OpenStackError("BadRequest", "user name and password are required", status_code=400)
scope = auth.get("scope") or {}
project_name = None
if "project" in scope:
project_name = (scope["project"] or {}).get("name")
if not project_name and (scope["project"] or {}).get("id"):
# resolve by id later via SQL
project_name = None
project_id = scope["project"]["id"]
else:
project_id = None
else:
project_id = None
host = request_public_host(request)
scheme = request_scheme(request)
async with pool.acquire() as conn:
if project_id and not project_name:
row = await conn.fetchrow(
"SELECT name FROM os_projects WHERE id = $1::uuid", project_id
)
if row is None:
raise OpenStackError("Unauthorized", "Project not found", status_code=401)
project_name = str(row["name"])
token_id, body = await issue_token(
conn,
user_name=str(user_name),
password=str(password),
project_name=str(project_name) if project_name else None,
domain_name=str(domain_name),
host=host,
scheme=scheme,
)
return JSONResponse(status_code=201, content=body, headers={"X-Subject-Token": token_id})
@router.get("/v3/auth/tokens")
async def show_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
subject = request.headers.get("X-Subject-Token") or extract_token(
{k: v for k, v in request.headers.items()}
)
if not subject:
raise OpenStackError("Unauthorized", "X-Subject-Token required", status_code=401)
# Also require caller token in normal Keystone, but lab accepts subject alone or auth token.
async with pool.acquire() as conn:
ctx = await validate_token(conn, subject)
domain = await conn.fetchrow(
"""SELECT d.id, d.name FROM os_domains d
JOIN os_users u ON u.domain_id = d.id WHERE u.id = $1""",
ctx.user_id,
)
host = request_public_host(request)
scheme = request_scheme(request)
body: dict[str, Any] = {
"token": {
"methods": ["password"],
"expires_at": ctx.expires_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"user": {
"id": str(ctx.user_id),
"name": ctx.user_name,
"domain": {
"id": str(domain["id"]) if domain else "",
"name": str(domain["name"]) if domain else "Default",
},
},
"roles": [{"id": r, "name": r} for r in ctx.roles],
}
}
if ctx.project_id is not None:
body["token"]["project"] = {
"id": str(ctx.project_id),
"name": ctx.project_name,
"domain": {
"id": str(domain["id"]) if domain else "",
"name": str(domain["name"]) if domain else "Default",
},
}
body["token"]["catalog"] = await build_catalog_from_db(conn, host, scheme=scheme)
return JSONResponse(content=body, headers={"X-Subject-Token": subject})
@router.delete("/v3/auth/tokens", status_code=204)
async def revoke_token(
request: Request,
pool: Annotated[Pool, Depends(get_pool)],
) -> Response:
subject = request.headers.get("X-Subject-Token")
if not subject:
raise OpenStackError("BadRequest", "X-Subject-Token required", status_code=400)
async with pool.acquire() as conn:
await conn.execute("UPDATE os_tokens SET revoked = true WHERE id = $1", subject)
return Response(status_code=204)
@router.get("/v3/auth/catalog")
async def auth_catalog(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
if ctx.project_id is None:
raise OpenStackError("Forbidden", "Project-scoped token required", status_code=403)
catalog = await build_catalog_from_db(
conn,
request_public_host(request),
scheme=request_scheme(request),
)
return {"catalog": catalog}
def _project_body(row: Any) -> dict[str, object]:
return {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"domain_id": str(row["domain_id"]),
"is_domain": False,
"parent_id": str(row["domain_id"]),
"links": {"self": f"/v3/projects/{row['id']}"},
}
def _user_body(row: Any) -> dict[str, object]:
return {
"id": str(row["id"]),
"name": row["name"],
"enabled": row["enabled"],
"domain_id": str(row["domain_id"]),
"links": {"self": f"/v3/users/{row['id']}"},
}
@router.get("/v3/projects")
async def list_projects(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
if ctx.is_admin:
rows = list(
await conn.fetch(
"SELECT id, name, description, enabled, domain_id FROM os_projects ORDER BY name, id"
)
)
else:
rows = list(
await conn.fetch(
"""SELECT p.id, p.name, p.description, p.enabled, p.domain_id
FROM os_projects p
JOIN os_role_assignments a ON a.project_id = p.id
WHERE a.user_id = $1
ORDER BY p.name, p.id""",
ctx.user_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {
"projects": [_project_body(r) for r in page],
"links": {"next": None, "previous": None, "self": "/v3/projects"},
}
if links:
body["projects_links"] = links
return body
@router.get("/v3/projects/{project_id}")
async def show_project(
project_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT id, name, description, enabled, domain_id FROM os_projects WHERE id = $1::uuid",
project_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find project: {project_id}", status_code=404)
if not ctx.is_admin:
allowed = await conn.fetchval(
"""SELECT 1 FROM os_role_assignments
WHERE user_id = $1 AND project_id = $2::uuid LIMIT 1""",
ctx.user_id,
project_id,
)
if not allowed and str(ctx.project_id or "") != project_id:
raise OpenStackError(
"Forbidden", "Not authorized to access this project", status_code=403
)
return {"project": _project_body(row)}
@router.get("/v3/users")
async def list_users(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
if not ctx.is_admin:
rows = list(
await conn.fetch(
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1",
ctx.user_id,
)
)
else:
rows = list(
await conn.fetch("SELECT id, name, enabled, domain_id FROM os_users ORDER BY name, id")
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"users": [_user_body(r) for r in page]}
if links:
body["users_links"] = links
return body
@router.get("/v3/users/{user_id}")
async def show_user(
user_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
if not ctx.is_admin and str(ctx.user_id) != user_id:
raise OpenStackError("Forbidden", "Not authorized to access this user", status_code=403)
row = await conn.fetchrow(
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1::uuid",
user_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find user: {user_id}", status_code=404)
return {"user": _user_body(row)}
@router.get("/v3/domains")
async def list_domains(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
rows = await conn.fetch(
"SELECT id, name, description, enabled FROM os_domains ORDER BY name, id"
)
return {
"domains": [
{
"id": str(r["id"]),
"name": r["name"],
"description": r["description"],
"enabled": r["enabled"],
"links": {"self": f"/v3/domains/{r['id']}"},
}
for r in rows
]
}
@router.get("/v3/domains/{domain_id}")
async def show_domain(
domain_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"""SELECT id, name, description, enabled FROM os_domains
WHERE id::text = $1 OR name = $1""",
domain_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find domain: {domain_id}", status_code=404)
return {
"domain": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"links": {"self": f"/v3/domains/{row['id']}"},
}
}
@router.get("/v3/roles")
async def list_roles(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
rows = await conn.fetch("SELECT id, name FROM os_roles ORDER BY name")
return {"roles": [{"id": str(r["id"]), "name": r["name"]} for r in rows]}
@router.get("/v3/roles/{role_id}")
async def show_role(
role_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT id, name FROM os_roles WHERE id::text = $1 OR name = $1",
role_id,
)
if row is None:
raise OpenStackError("NotFound", f"Could not find role: {role_id}", status_code=404)
return {"role": {"id": str(row["id"]), "name": row["name"]}}
@router.post("/v3/projects", status_code=201)
async def create_project(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("project") or {}
domain_id = payload.get("domain_id") or await conn.fetchval(
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
)
row = await conn.fetchrow(
"""INSERT INTO os_projects(id, domain_id, name, description, enabled)
VALUES($1,$2,$3,$4,$5) RETURNING id, name, description, enabled, domain_id""",
uuid4(),
domain_id,
str(payload.get("name") or f"project-{uuid4().hex[:8]}"),
payload.get("description") or "",
bool(payload.get("enabled", True)),
)
_ = ctx
return {"project": _project_body(row)}
@router.post("/v3/users", status_code=201)
async def create_user(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
from app.security.auth import hash_secret
payload = (await request.json()).get("user") or {}
domain_id = payload.get("domain_id") or await conn.fetchval(
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
)
password = str(payload.get("password") or "secret")
row = await conn.fetchrow(
"""INSERT INTO os_users(id, domain_id, name, password_hash, enabled)
VALUES($1,$2,$3,$4,$5) RETURNING id, name, enabled, domain_id""",
uuid4(),
domain_id,
str(payload.get("name") or f"user-{uuid4().hex[:8]}"),
hash_secret(password, salt=b"openstack-sim-v1"),
bool(payload.get("enabled", True)),
)
_ = ctx
return {"user": _user_body(row)}
@router.post("/v3/domains", status_code=201)
async def create_domain(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("domain") or {}
row = await conn.fetchrow(
"""INSERT INTO os_domains(id, name, description, enabled)
VALUES($1,$2,$3,$4) RETURNING id, name, description, enabled""",
uuid4(),
str(payload.get("name") or f"domain-{uuid4().hex[:8]}"),
payload.get("description") or "",
bool(payload.get("enabled", True)),
)
_ = ctx
return {
"domain": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"enabled": row["enabled"],
"links": {"self": f"/v3/domains/{row['id']}"},
}
}
@router.post("/v3/roles", status_code=201)
async def create_role(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
from uuid import uuid4
payload = (await request.json()).get("role") or {}
row = await conn.fetchrow(
"""INSERT INTO os_roles(id, name) VALUES($1,$2) RETURNING id, name""",
uuid4(),
str(payload.get("name") or f"role-{uuid4().hex[:8]}"),
)
_ = ctx
return {"role": {"id": str(row["id"]), "name": row["name"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
"""Octavia Load Balancer API v2."""
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_project_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Octavia"])
def _lb(row: Any) -> dict[str, Any]:
return {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
"project_id": str(row["project_id"]),
"vip_address": row["vip_address"],
"vip_subnet_id": str(row["vip_subnet_id"]) if row["vip_subnet_id"] else None,
"provisioning_status": row["provisioning_status"],
"operating_status": row["operating_status"],
"listeners": row["listeners"]
if not isinstance(row["listeners"], str)
else json.loads(row["listeners"]),
"pools": row["pools"] if not isinstance(row["pools"], str) else json.loads(row["pools"]),
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%S"),
}
@router.get("/v2")
@router.get("/v2/")
@router.get("/v2.0")
@router.get("/v2.0/")
async def octavia_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(
conn, service="octavia", resource_type="discovery_version", name="default"
)
@router.get("/v2/lbaas/loadbalancers")
@router.get("/v2.0/lbaas/loadbalancers")
async def list_lbs(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.paging import paginate_rows
rows = list(
await conn.fetch(
"SELECT * FROM os_loadbalancers WHERE project_id=$1 ORDER BY created_at, id",
ctx.project_id,
)
)
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
body: dict[str, object] = {"loadbalancers": [_lb(r) for r in page]}
if links:
body["loadbalancers_links"] = links
return body
@router.post("/v2/lbaas/loadbalancers", status_code=201)
@router.post("/v2.0/lbaas/loadbalancers", status_code=201)
async def create_lb(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
payload = (await request.json()).get("loadbalancer") or {}
defaults = (
await fetch_doc(
conn, service="octavia", resource_type="loadbalancer_defaults", name="default"
)
or {}
)
row = await conn.fetchrow(
"""INSERT INTO os_loadbalancers(id, project_id, name, description, vip_address, vip_subnet_id, provisioning_status, operating_status)
VALUES($1,$2,$3,$4,$5,$6::uuid,'ACTIVE','ONLINE') RETURNING *""",
uuid4(),
ctx.project_id,
payload.get("name") or defaults.get("name") or "lb",
payload.get("description") or "",
payload.get("vip_address") or defaults.get("vip_address"),
payload.get("vip_subnet_id"),
)
return {"loadbalancer": _lb(row)}
@router.get("/v2/lbaas/loadbalancers/{lb_id}")
@router.get("/v2.0/lbaas/loadbalancers/{lb_id}")
async def show_lb(
lb_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"SELECT * FROM os_loadbalancers WHERE id::text=$1 AND project_id=$2",
lb_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return {"loadbalancer": _lb(row)}
async def _update_lb(
resource_id: str,
request: Request,
conn: Connection,
ctx: TokenContext,
) -> dict[str, object]:
payload = (await request.json()).get("loadbalancer") or {}
row = await conn.fetchrow(
"""UPDATE os_loadbalancers
SET name = COALESCE($1, name),
description = COALESCE($2, description)
WHERE id::text = $3 AND project_id = $4
RETURNING *""",
payload.get("name"),
payload.get("description"),
resource_id,
ctx.project_id,
)
if row is None:
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return {"loadbalancer": _lb(row)}
@router.put("/v2/lbaas/loadbalancers/{lb_id}")
@router.put("/v2.0/lbaas/loadbalancers/{lb_id}")
@router.patch("/v2/lbaas/loadbalancers/{lb_id}")
@router.patch("/v2.0/lbaas/loadbalancers/{lb_id}")
async def update_lb(
lb_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_lb(lb_id, request, conn, ctx)
@router.put("/v2/lbaas/loadbalancers/{id}")
@router.put("/v2.0/lbaas/loadbalancers/{id}")
@router.patch("/v2/lbaas/loadbalancers/{id}")
@router.patch("/v2.0/lbaas/loadbalancers/{id}")
async def update_lb_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_lb(id, request, conn, ctx)
@router.delete("/v2/lbaas/loadbalancers/{lb_id}", status_code=204)
@router.delete("/v2.0/lbaas/loadbalancers/{lb_id}", status_code=204)
async def delete_lb(
lb_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
result = await conn.execute(
"DELETE FROM os_loadbalancers WHERE id::text=$1 AND project_id=$2",
lb_id,
ctx.project_id,
)
if result.endswith("0"):
raise OpenStackError("NotFound", "Load balancer not found", status_code=404)
return Response(status_code=204)
@router.get("/v2/lbaas/listeners")
@router.get("/v2.0/lbaas/listeners")
@router.get("/v2/lbaas/pools")
@router.get("/v2.0/lbaas/pools")
@router.get("/v2/lbaas/healthmonitors")
@router.get("/v2.0/lbaas/healthmonitors")
@router.get("/v2/lbaas/providers")
@router.get("/v2.0/lbaas/providers")
@router.get("/v2/lbaas/flavors")
@router.get("/v2.0/lbaas/flavors")
async def octavia_extension_collections(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
"""Serve Octavia side collections from demo/schema rows."""
import json as _json
key = request.url.path.rstrip("/").split("/")[-1]
resource_type = {
"listeners": "listener",
"pools": "pool",
"healthmonitors": "healthmonitor",
"flavors": "flavor",
"providers": "provider",
}.get(key, key)
rows = await conn.fetch(
"""SELECT id, name, status, data FROM os_api_objects
WHERE service='octavia' AND resource_type=$1
AND (project_id=$2 OR project_id IS NULL)
ORDER BY created_at NULLS LAST, id""",
resource_type,
ctx.project_id,
)
items: list[dict[str, object]] = []
for row in rows:
data = row["data"] if isinstance(row["data"], dict) else _json.loads(row["data"] or "{}")
item = {"id": str(row["id"]), "name": row["name"], **data}
item["id"] = str(row["id"])
items.append(item)
return {key: items}
+91
View File
@@ -0,0 +1,91 @@
"""Placement API (lab subset + demo inventory)."""
from __future__ import annotations
import json
from typing import Annotated, Any
from asyncpg import Connection
from fastapi import APIRouter, Depends
from app.openstack.auth import TokenContext
from app.openstack.db_docs import fetch_doc
from app.openstack.deps import get_conn, require_token
router = APIRouter(tags=["Placement"])
@router.get("/resource_providers")
async def list_resource_providers(
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
defaults = await fetch_doc(
conn, service="placement", resource_type="resource_provider_defaults", name="default"
)
default_generation = int((defaults or {}).get("generation") or 0)
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service='placement' AND resource_type='resource_provider'
ORDER BY created_at, name"""
)
providers: list[dict[str, Any]] = []
for row in rows:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
providers.append(
{
"id": str(row["id"]),
"uuid": str(row["id"]),
"name": row["name"] or data.get("name") or str(row["id"]),
"generation": int(
data.get("generation")
if data.get("generation") is not None
else default_generation
),
"parent_provider_uuid": data.get("parent_provider_uuid"),
}
)
return {"resource_providers": providers}
@router.get("/allocations/{consumer_uuid}")
async def show_allocations(
consumer_uuid: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_token)],
) -> dict[str, object]:
defaults = await fetch_doc(
conn, service="placement", resource_type="allocation_defaults", name="default"
)
default_resources = dict((defaults or {}).get("resources") or {})
consumer_generation = int((defaults or {}).get("consumer_generation") or 0)
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service='placement' AND resource_type='allocation'
AND (data->>'consumer_uuid'=$1 OR id::text=$1 OR name=$1)
ORDER BY created_at""",
consumer_uuid,
)
allocations: dict[str, Any] = {}
for row in rows:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
data = dict(data or {})
rp = str(data.get("resource_provider") or data.get("resource_provider_id") or row["id"])
resources = (
data.get("resources") if isinstance(data.get("resources"), dict) else default_resources
)
allocations[rp] = {"resources": resources}
if data.get("consumer_generation") is not None:
consumer_generation = int(data["consumer_generation"])
if not allocations and default_resources:
allocations["00000000-0000-0000-0000-000000000001"] = {"resources": default_resources}
elif not allocations:
allocations["00000000-0000-0000-0000-000000000001"] = {
"resources": {"VCPU": 1, "MEMORY_MB": 512}
}
return {"allocations": allocations, "consumer_generation": consumer_generation}
+61
View File
@@ -0,0 +1,61 @@
"""Port-aware root / version discovery (and HTML console for browsers)."""
from __future__ import annotations
from typing import Annotated
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.openstack.db_docs import require_doc
from app.openstack.deps import get_conn
from app.openstack.dispatch import resolve_service
from app.web.assets import console_html
router = APIRouter(tags=["OpenStack"])
def _service_name(request: Request) -> str:
headers = {k.lower(): v for k, v in request.headers.items()}
# Prefer explicit gateway port/service; also pass path for disambiguation.
resolved = resolve_service(headers, path=request.url.path)
if resolved and resolved not in ("", "https"):
return resolved
# Fallback: Host:port when proxies strip/alter X-Forwarded-Port.
host = headers.get("host") or ""
if ":" in host:
try:
port = int(host.rsplit(":", 1)[1])
except ValueError:
port = None
if port is not None:
from app.openstack.dispatch import _PORT_TO_SERVICE
by_host = _PORT_TO_SERVICE.get(port)
if by_host:
return by_host
return "keystone"
def _wants_html(request: Request) -> bool:
accept = (request.headers.get("accept") or "*/*").lower()
if accept.startswith("application/json"):
return False
return "text/html" in accept.split(",")[0] or (
"text/html" in accept and "application/json" not in accept
)
async def _json_versions(conn: Connection, name: str) -> dict[str, object]:
return await require_doc(conn, service=name, resource_type="discovery_version", name="default")
@router.get("/")
async def root(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
):
if _wants_html(request):
return HTMLResponse(console_html(), headers={"Cache-Control": "no-store"})
return JSONResponse(await _json_versions(conn, _service_name(request)))
+185
View File
@@ -0,0 +1,185 @@
"""Swift Object Storage API v1."""
from __future__ import annotations
import json
from typing import Annotated
from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import PlainTextResponse
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_token
from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Swift"])
def _account(ctx: TokenContext) -> str:
return f"AUTH_{ctx.project_id or ctx.user_id}"
@router.get("/info")
async def swift_info(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
from app.openstack.db_docs import require_doc
return await require_doc(conn, service="swift", resource_type="info", name="default")
@router.get("/v1/{account}")
async def list_containers(
account: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> list[dict[str, object]]:
_ = account
rows = await conn.fetch(
"SELECT name, meta, created_at FROM os_swift_containers WHERE account=$1 ORDER BY name",
_account(ctx),
)
result = []
for r in rows:
count = await conn.fetchval(
"SELECT count(*) FROM os_swift_objects WHERE account=$1 AND container=$2",
_account(ctx),
r["name"],
)
bytes_total = await conn.fetchval(
"SELECT COALESCE(sum(bytes),0) FROM os_swift_objects WHERE account=$1 AND container=$2",
_account(ctx),
r["name"],
)
result.append({"name": r["name"], "count": int(count or 0), "bytes": int(bytes_total or 0)})
return result
@router.put("/v1/{account}/{container}", status_code=201)
async def create_container(
account: str,
container: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
await conn.execute(
"""INSERT INTO os_swift_containers(account, name, meta)
VALUES($1,$2,'{}'::jsonb) ON CONFLICT DO NOTHING""",
_account(ctx),
container,
)
return Response(status_code=201)
@router.get("/v1/{account}/{container}")
async def list_objects(
account: str,
container: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> list[dict[str, object]]:
_ = account
rows = await conn.fetch(
"""SELECT name, bytes, content_type, created_at FROM os_swift_objects
WHERE account=$1 AND container=$2 ORDER BY name""",
_account(ctx),
container,
)
return [
{
"name": r["name"],
"bytes": r["bytes"],
"content_type": r["content_type"],
"last_modified": r["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
"hash": "0",
}
for r in rows
]
@router.put("/v1/{account}/{container}/{object_name:path}", status_code=201)
async def put_object(
account: str,
container: str,
object_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
body = await request.body()
await conn.execute(
"""INSERT INTO os_swift_containers(account, name, meta)
VALUES($1,$2,'{}'::jsonb) ON CONFLICT DO NOTHING""",
_account(ctx),
container,
)
await conn.execute(
"""INSERT INTO os_swift_objects(id, account, container, name, content_type, bytes, body, meta)
VALUES($1,$2,$3,$4,$5,$6,$7,'{}'::jsonb)
ON CONFLICT (account, container, name) DO UPDATE
SET bytes=EXCLUDED.bytes, body=EXCLUDED.body, content_type=EXCLUDED.content_type""",
uuid4(),
_account(ctx),
container,
object_name,
request.headers.get("content-type") or "application/octet-stream",
len(body),
body,
)
return Response(status_code=201, headers={"Etag": "0"})
@router.post("/v1/{account}/{container}/{object_name:path}", status_code=202)
async def post_object(
account: str,
container: str,
object_name: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
# Metadata update / create — reuse PUT semantics
return await put_object(account, container, object_name, request, conn, ctx)
@router.get("/v1/{account}/{container}/{object_name:path}")
async def get_object(
account: str,
container: str,
object_name: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
row = await conn.fetchrow(
"""SELECT body, content_type FROM os_swift_objects
WHERE account=$1 AND container=$2 AND name=$3""",
_account(ctx),
container,
object_name,
)
if row is None:
raise OpenStackError("NotFound", "Object not found", status_code=404)
return Response(content=bytes(row["body"] or b""), media_type=row["content_type"])
@router.delete("/v1/{account}/{container}/{object_name:path}", status_code=204)
async def delete_object(
account: str,
container: str,
object_name: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
result = await conn.execute(
"DELETE FROM os_swift_objects WHERE account=$1 AND container=$2 AND name=$3",
_account(ctx),
container,
object_name,
)
if result.endswith("0"):
raise OpenStackError("NotFound", "Object not found", status_code=404)
return Response(status_code=204)