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