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:
2026-07-18 09:38:45 +03:00
parent 6cffb6a95f
commit 147f04c9a2
29 changed files with 843 additions and 346 deletions
+5 -5
View File
@@ -340,8 +340,7 @@ async def seed_openstack_demo(
for i, az in enumerate(AZS):
hosts = [
f"compute-{(j // len(AZS)) + 1:02d}.{az}"
for j in range(i, hypervisor_count, len(AZS))
f"compute-{(j // len(AZS)) + 1:02d}.{az}" for j in range(i, hypervisor_count, len(AZS))
]
await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
@@ -586,8 +585,7 @@ async def seed_openstack_demo(
# Distribute servers across all lab projects (incl. admin — tokens often use admin)
tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo")
hypervisor_names = [
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}"
for i in range(hypervisor_count)
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}" for i in range(hypervisor_count)
]
server_rows = []
@@ -1494,7 +1492,9 @@ async def seed_openstack_demo(
# Nova server groups (specialized table) — denser in demo project
for i in range(server_group_count):
pname = "demo" if i < max(1, server_group_count // 2) else tenant_cycle[i % len(tenant_cycle)]
pname = (
"demo" if i < max(1, server_group_count // 2) else tenant_cycle[i % len(tenant_cycle)]
)
span = max(1, min(8, max(1, server_count // 4)))
members = [
str(oid(f"server:demo:{(3 + (i % span) * 6) % server_count}")),
+6 -8
View File
@@ -101,9 +101,7 @@ def flatten_schema_fields(
child_type = next((t for t in child_type if t != "null"), child_type[0])
nested_props = child.get("properties")
if (child_type == "object" or nested_props) and isinstance(nested_props, dict):
fields.extend(
flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1)
)
fields.extend(flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1))
elif child_type == "array":
items = child.get("items")
if isinstance(items, dict) and (
@@ -111,9 +109,7 @@ def flatten_schema_fields(
):
# Expand one sample element so nested array object fields appear.
fields.extend(
flatten_schema_fields(
items, prefix=f"{path}.0", max_depth=max_depth - 1
)
flatten_schema_fields(items, prefix=f"{path}.0", max_depth=max_depth - 1)
)
else:
fields.append(
@@ -231,8 +227,10 @@ def unflatten_body(values: dict[str, Any]) -> dict[str, Any]:
return
while len(cur) <= idx:
cur.append([] if want_array else {})
if cur[idx] is None or (want_array and not isinstance(cur[idx], list)) or (
not want_array and not isinstance(cur[idx], dict)
if (
cur[idx] is None
or (want_array and not isinstance(cur[idx], list))
or (not want_array and not isinstance(cur[idx], dict))
):
cur[idx] = [] if want_array else {}
cur = cur[idx]
+71 -1
View File
@@ -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
View File
@@ -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")
+16
View File
@@ -459,6 +459,22 @@ async def seed_discovery_documents(conn: Connection) -> dict[str, int]:
}
},
),
(
"cinder",
"quota_set_defaults",
"default",
{
"quota_set": {
"volumes": 100,
"snapshots": 100,
"gigabytes": 1000,
"backups": 10,
"backup_gigabytes": 1000,
"groups": 10,
"per_volume_gigabytes": -1,
}
},
),
(
"nova",
"console_auth_token_defaults",
+6 -6
View File
@@ -496,7 +496,11 @@ def probe_operation(
base = service_base_url(host, pack.name, port=pack.port)
url = f"{base}{path}"
method = (method_override or op.method).upper()
data = None if method in {"GET", "HEAD", "DELETE"} else _body_for(op, ctx=path_ctx, project_id=project_id)
data = (
None
if method in {"GET", "HEAD", "DELETE"}
else _body_for(op, ctx=path_ctx, project_id=project_id)
)
mv = microversion_headers(pack, op)
status, payload = http_request(
method,
@@ -886,11 +890,7 @@ def probe_series_lifecycle(
local["server_id"] = rid
# Swift: empty the container before DELETE so the API returns 204
# (409 Conflict for a non-empty container is correct OpenStack behaviour).
if (
op.method == "DELETE"
and pack.name == "swift"
and op.resource_type == "container"
):
if op.method == "DELETE" and pack.name == "swift" and op.resource_type == "container":
acct = local.get("account") or project_id
cname = local.get("container") or local.get("id")
if acct and cname: