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,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)
|
||||
Reference in New Issue
Block a user