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:
@@ -0,0 +1 @@
|
||||
"""HTTP routers for OpenStack services."""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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}}}
|
||||
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user