Fix Nova/Cinder stateful CRUD so the full surface and Pulumi matrix pass clean.
Persist aggregates and server security groups in domain tables, add Cinder quota show from DB-backed defaults, align unit tests with OpenStack series, auto-pick Keystone :5000/:15000 for smoke, and document test locations plus 6514/6514 results.
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated, Any
|
||||
from uuid import uuid4
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
from asyncpg import Connection
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
@@ -14,6 +15,16 @@ from app.openstack.errors import OpenStackError
|
||||
|
||||
router = APIRouter(tags=["Cinder"])
|
||||
|
||||
_DEFAULT_QUOTA_SET: dict[str, object] = {
|
||||
"volumes": 100,
|
||||
"snapshots": 100,
|
||||
"gigabytes": 1000,
|
||||
"backups": 10,
|
||||
"backup_gigabytes": 1000,
|
||||
"groups": 10,
|
||||
"per_volume_gigabytes": -1,
|
||||
}
|
||||
|
||||
|
||||
def _volume(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -46,6 +57,65 @@ async def cinder_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dic
|
||||
)
|
||||
|
||||
|
||||
async def _quota_set_for(
|
||||
conn: Connection,
|
||||
*,
|
||||
tenant_id: str,
|
||||
project_id: Any,
|
||||
) -> dict[str, object]:
|
||||
from app.openstack.db_docs import fetch_doc
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT data FROM os_api_objects
|
||||
WHERE service='cinder' AND resource_type='quota_set'
|
||||
AND (id::text=$1 OR name=$1 OR data->>'id'=$1 OR data->>'tenant_id'=$1)
|
||||
ORDER BY updated_at DESC LIMIT 1""",
|
||||
tenant_id,
|
||||
)
|
||||
if row is not None:
|
||||
data = row["data"]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
quota = dict((data or {}).get("quota_set") or data or {})
|
||||
quota.setdefault("id", tenant_id)
|
||||
return {"quota_set": quota}
|
||||
defaults = (
|
||||
await fetch_doc(conn, service="cinder", resource_type="quota_set_defaults", name="default")
|
||||
or {}
|
||||
)
|
||||
quota = dict((defaults.get("quota_set") or _DEFAULT_QUOTA_SET))
|
||||
quota["id"] = tenant_id
|
||||
# Stable per-tenant id that does not collide with Nova's project-UUID quota rows.
|
||||
item_id = uuid5(NAMESPACE_URL, f"cinder:quota_set:{tenant_id}")
|
||||
await conn.execute(
|
||||
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||
VALUES($1,'cinder','quota_set',$2,$3,'ACTIVE',$4::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data, updated_at=now()""",
|
||||
item_id,
|
||||
project_id,
|
||||
tenant_id,
|
||||
json.dumps({"id": tenant_id, "tenant_id": tenant_id, "quota_set": quota}),
|
||||
)
|
||||
return {"quota_set": quota}
|
||||
|
||||
|
||||
@router.get("/v3/os-quota-sets/{tenant_id}")
|
||||
@router.get("/v3/os-quota-sets/{id}")
|
||||
@router.get("/v3/{project_id}/os-quota-sets/{tenant_id}")
|
||||
@router.get("/v3/{project_id}/os-quota-sets/{id}")
|
||||
async def show_quota_set(
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
tenant_id: str | None = None,
|
||||
id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
_ = project_id
|
||||
return await _quota_set_for(
|
||||
conn, tenant_id=tenant_id or id or str(ctx.project_id), project_id=ctx.project_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/v3/{project_id}/volumes")
|
||||
@router.get("/v3/{project_id}/volumes/detail")
|
||||
@router.get("/v3/volumes")
|
||||
|
||||
+280
-27
@@ -929,12 +929,36 @@ async def list_aggregates(
|
||||
return {"aggregates": [_aggregate_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/v2.1/os-aggregates/{aggregate_id}")
|
||||
async def show_aggregate(
|
||||
aggregate_id: str,
|
||||
@router.post("/v2.1/os-aggregates", status_code=201)
|
||||
async def create_aggregate(
|
||||
request: Request,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
payload = (await request.json()).get("aggregate") or {}
|
||||
name = str(payload.get("name") or f"agg-{uuid4().hex[:8]}")
|
||||
az = payload.get("availability_zone")
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
hosts = payload.get("hosts") if isinstance(payload.get("hosts"), list) else []
|
||||
next_id = await conn.fetchval("SELECT COALESCE(MAX(id), 0) + 1 FROM os_aggregates")
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
|
||||
VALUES($1,$2,$3,$4::jsonb,$5::jsonb)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
availability_zone=EXCLUDED.availability_zone,
|
||||
hosts=EXCLUDED.hosts,
|
||||
metadata=EXCLUDED.metadata
|
||||
RETURNING *""",
|
||||
int(next_id or 1),
|
||||
name,
|
||||
None if az is None else str(az),
|
||||
json.dumps(hosts),
|
||||
json.dumps(metadata),
|
||||
)
|
||||
return {"aggregate": _aggregate_dict(row)}
|
||||
|
||||
|
||||
async def _fetch_aggregate(conn: Connection, aggregate_id: str) -> Any:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT * FROM os_aggregates
|
||||
WHERE id::text=$1 OR name=$1
|
||||
@@ -943,7 +967,106 @@ async def show_aggregate(
|
||||
)
|
||||
if row is None:
|
||||
raise OpenStackError("NotFound", f"aggregate {aggregate_id} not found", status_code=404)
|
||||
return {"aggregate": _aggregate_dict(row)}
|
||||
return row
|
||||
|
||||
|
||||
async def _show_aggregate_impl(conn: Connection, aggregate_id: str) -> dict[str, object]:
|
||||
return {"aggregate": _aggregate_dict(await _fetch_aggregate(conn, aggregate_id))}
|
||||
|
||||
|
||||
async def _update_aggregate_impl(
|
||||
request: Request, conn: Connection, aggregate_id: str
|
||||
) -> dict[str, object]:
|
||||
row = await _fetch_aggregate(conn, aggregate_id)
|
||||
body = await request.json()
|
||||
payload = body.get("aggregate") if isinstance(body.get("aggregate"), dict) else body
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
name = str(payload.get("name") or row["name"])
|
||||
az = payload.get("availability_zone", row["availability_zone"])
|
||||
current = _aggregate_dict(row)
|
||||
metadata = (
|
||||
payload.get("metadata")
|
||||
if isinstance(payload.get("metadata"), dict)
|
||||
else current["metadata"]
|
||||
)
|
||||
hosts = payload.get("hosts") if isinstance(payload.get("hosts"), list) else current["hosts"]
|
||||
updated = await conn.fetchrow(
|
||||
"""UPDATE os_aggregates
|
||||
SET name=$2, availability_zone=$3, hosts=$4::jsonb, metadata=$5::jsonb
|
||||
WHERE id=$1
|
||||
RETURNING *""",
|
||||
row["id"],
|
||||
name,
|
||||
None if az is None else str(az),
|
||||
json.dumps(hosts),
|
||||
json.dumps(metadata),
|
||||
)
|
||||
return {"aggregate": _aggregate_dict(updated)}
|
||||
|
||||
|
||||
async def _delete_aggregate_impl(conn: Connection, aggregate_id: str) -> Response:
|
||||
row = await _fetch_aggregate(conn, aggregate_id)
|
||||
await conn.execute("DELETE FROM os_aggregates WHERE id=$1", row["id"])
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/v2.1/os-aggregates/{aggregate_id}")
|
||||
async def show_aggregate(
|
||||
aggregate_id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
return await _show_aggregate_impl(conn, aggregate_id)
|
||||
|
||||
|
||||
@router.get("/v2.1/os-aggregates/{id}")
|
||||
async def show_aggregate_by_id(
|
||||
id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
return await _show_aggregate_impl(conn, id)
|
||||
|
||||
|
||||
@router.put("/v2.1/os-aggregates/{aggregate_id}")
|
||||
@router.patch("/v2.1/os-aggregates/{aggregate_id}")
|
||||
async def update_aggregate(
|
||||
aggregate_id: str,
|
||||
request: Request,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
return await _update_aggregate_impl(request, conn, aggregate_id)
|
||||
|
||||
|
||||
@router.put("/v2.1/os-aggregates/{id}")
|
||||
@router.patch("/v2.1/os-aggregates/{id}")
|
||||
async def update_aggregate_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_aggregate_impl(request, conn, id)
|
||||
|
||||
|
||||
@router.delete("/v2.1/os-aggregates/{aggregate_id}", status_code=204)
|
||||
async def delete_aggregate(
|
||||
aggregate_id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> Response:
|
||||
return await _delete_aggregate_impl(conn, aggregate_id)
|
||||
|
||||
|
||||
@router.delete("/v2.1/os-aggregates/{id}", status_code=204)
|
||||
async def delete_aggregate_by_id(
|
||||
id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> Response:
|
||||
return await _delete_aggregate_impl(conn, id)
|
||||
|
||||
|
||||
@router.get("/v2.1/os-services")
|
||||
@@ -1559,6 +1682,33 @@ async def delete_server_tag(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _server_sg_dict(row: Any) -> dict[str, object]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_server_security_group(
|
||||
conn: Connection, project_id: UUID, security_group_id: str
|
||||
) -> Any:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT * FROM os_security_groups
|
||||
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
|
||||
LIMIT 1""",
|
||||
project_id,
|
||||
security_group_id,
|
||||
)
|
||||
if row is None:
|
||||
raise OpenStackError(
|
||||
"NotFound",
|
||||
f"security_group {security_group_id} not found",
|
||||
status_code=404,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/v2.1/servers/{server_id}/os-security-groups")
|
||||
async def server_security_groups(
|
||||
server_id: str,
|
||||
@@ -1570,13 +1720,76 @@ async def server_security_groups(
|
||||
"SELECT * FROM os_security_groups WHERE project_id=$1 ORDER BY name",
|
||||
ctx.project_id,
|
||||
)
|
||||
return {"security_groups": [_server_sg_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/v2.1/servers/{server_id}/os-security-groups", status_code=201)
|
||||
async def create_server_security_group(
|
||||
server_id: str,
|
||||
request: Request,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
_ = server_id
|
||||
payload = (await request.json()).get("security_group") or {}
|
||||
sg_name = str(payload.get("name") or f"sg-{uuid4().hex[:8]}")
|
||||
sg_id = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO os_security_groups(id, project_id, name, description)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
sg_id,
|
||||
ctx.project_id,
|
||||
sg_name,
|
||||
str(payload.get("description") or ""),
|
||||
)
|
||||
return {
|
||||
"security_groups": [
|
||||
{"id": str(r["id"]), "name": r["name"], "description": r["description"]} for r in rows
|
||||
]
|
||||
"security_group": {
|
||||
"id": str(sg_id),
|
||||
"name": sg_name,
|
||||
"description": str(payload.get("description") or ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _show_server_sg_impl(
|
||||
conn: Connection, project_id: UUID, security_group_id: str
|
||||
) -> dict[str, object]:
|
||||
row = await _fetch_server_security_group(conn, project_id, security_group_id)
|
||||
return {"security_group": _server_sg_dict(row)}
|
||||
|
||||
|
||||
async def _update_server_sg_impl(
|
||||
request: Request, conn: Connection, project_id: UUID, security_group_id: str
|
||||
) -> dict[str, object]:
|
||||
row = await _fetch_server_security_group(conn, project_id, security_group_id)
|
||||
body = await request.json()
|
||||
payload = body.get("security_group") if isinstance(body.get("security_group"), dict) else body
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
name = str(payload.get("name") or row["name"])
|
||||
description = str(
|
||||
payload.get("description") if "description" in payload else row["description"]
|
||||
)
|
||||
updated = await conn.fetchrow(
|
||||
"""UPDATE os_security_groups
|
||||
SET name=$2, description=$3
|
||||
WHERE id=$1
|
||||
RETURNING *""",
|
||||
row["id"],
|
||||
name,
|
||||
description,
|
||||
)
|
||||
return {"security_group": _server_sg_dict(updated)}
|
||||
|
||||
|
||||
async def _delete_server_sg_impl(
|
||||
conn: Connection, project_id: UUID, security_group_id: str
|
||||
) -> Response:
|
||||
row = await _fetch_server_security_group(conn, project_id, security_group_id)
|
||||
await conn.execute("DELETE FROM os_security_groups WHERE id=$1", row["id"])
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
|
||||
async def show_server_security_group(
|
||||
server_id: str,
|
||||
@@ -1585,26 +1798,66 @@ async def show_server_security_group(
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
_ = server_id
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT * FROM os_security_groups
|
||||
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
|
||||
LIMIT 1""",
|
||||
ctx.project_id,
|
||||
security_group_id,
|
||||
)
|
||||
if row is None:
|
||||
raise OpenStackError(
|
||||
"NotFound",
|
||||
f"security_group {security_group_id} not found",
|
||||
status_code=404,
|
||||
)
|
||||
return {
|
||||
"security_group": {
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
}
|
||||
}
|
||||
return await _show_server_sg_impl(conn, ctx.project_id, security_group_id)
|
||||
|
||||
|
||||
@router.get("/v2.1/servers/{server_id}/os-security-groups/{id}")
|
||||
async def show_server_security_group_by_id(
|
||||
server_id: str,
|
||||
id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
_ = server_id
|
||||
return await _show_server_sg_impl(conn, ctx.project_id, id)
|
||||
|
||||
|
||||
@router.put("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
|
||||
@router.patch("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
|
||||
async def update_server_security_group(
|
||||
server_id: str,
|
||||
security_group_id: str,
|
||||
request: Request,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
_ = server_id
|
||||
return await _update_server_sg_impl(request, conn, ctx.project_id, security_group_id)
|
||||
|
||||
|
||||
@router.put("/v2.1/servers/{server_id}/os-security-groups/{id}")
|
||||
@router.patch("/v2.1/servers/{server_id}/os-security-groups/{id}")
|
||||
async def update_server_security_group_by_id(
|
||||
server_id: str,
|
||||
id: str,
|
||||
request: Request,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> dict[str, object]:
|
||||
_ = server_id
|
||||
return await _update_server_sg_impl(request, conn, ctx.project_id, id)
|
||||
|
||||
|
||||
@router.delete("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}", status_code=204)
|
||||
async def delete_server_security_group(
|
||||
server_id: str,
|
||||
security_group_id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> Response:
|
||||
_ = server_id
|
||||
return await _delete_server_sg_impl(conn, ctx.project_id, security_group_id)
|
||||
|
||||
|
||||
@router.delete("/v2.1/servers/{server_id}/os-security-groups/{id}", status_code=204)
|
||||
async def delete_server_security_group_by_id(
|
||||
server_id: str,
|
||||
id: str,
|
||||
conn: Annotated[Connection, Depends(get_conn)],
|
||||
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||
) -> Response:
|
||||
_ = server_id
|
||||
return await _delete_server_sg_impl(conn, ctx.project_id, id)
|
||||
|
||||
|
||||
@router.get("/v2.1/servers/{server_id}/topology")
|
||||
|
||||
Reference in New Issue
Block a user