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
+2
View File
@@ -173,6 +173,8 @@ open pulumi-tests/reports/pulumi-report.html
``` ```
Details: **[docs/hypervisor-lab.md](docs/hypervisor-lab.md)**. Details: **[docs/hypervisor-lab.md](docs/hypervisor-lab.md)**.
Latest full-matrix result (2026-07-18): HTTP **6514 / 6514**, `http_critical=0`
(yoga→dalmatian). Pytest / smoke map: [docs/operations.md — Testing](docs/operations.md#testing).
Full matrix of **real OpenStack default ports** published 1:1 (Keystone `:5000`, Full matrix of **real OpenStack default ports** published 1:1 (Keystone `:5000`,
Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): see Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): see
+2
View File
@@ -173,6 +173,8 @@ open pulumi-tests/reports/pulumi-report.html
``` ```
Подробности: **[docs/ru/hypervisor-lab.md](docs/ru/hypervisor-lab.md)**. Подробности: **[docs/ru/hypervisor-lab.md](docs/ru/hypervisor-lab.md)**.
Последний полный прогон (2026-07-18): HTTP **6514 / 6514**, `http_critical=0`
(yoga→dalmatian). Карта pytest / smoke: [docs/ru/operations.md — Тестирование](docs/ru/operations.md#тестирование).
Полная матрица **реальных портов OpenStack по умолчанию**, публикуемых 1:1 Полная матрица **реальных портов OpenStack по умолчанию**, публикуемых 1:1
(Keystone `:5000`, Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): (Keystone `:5000`, Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …):
+5 -5
View File
@@ -340,8 +340,7 @@ async def seed_openstack_demo(
for i, az in enumerate(AZS): for i, az in enumerate(AZS):
hosts = [ hosts = [
f"compute-{(j // len(AZS)) + 1:02d}.{az}" f"compute-{(j // len(AZS)) + 1:02d}.{az}" for j in range(i, hypervisor_count, len(AZS))
for j in range(i, hypervisor_count, len(AZS))
] ]
await conn.execute( await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata) """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) # Distribute servers across all lab projects (incl. admin — tokens often use admin)
tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo") tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo")
hypervisor_names = [ hypervisor_names = [
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}" f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}" for i in range(hypervisor_count)
for i in range(hypervisor_count)
] ]
server_rows = [] server_rows = []
@@ -1494,7 +1492,9 @@ async def seed_openstack_demo(
# Nova server groups (specialized table) — denser in demo project # Nova server groups (specialized table) — denser in demo project
for i in range(server_group_count): 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))) span = max(1, min(8, max(1, server_count // 4)))
members = [ members = [
str(oid(f"server:demo:{(3 + (i % span) * 6) % server_count}")), 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]) child_type = next((t for t in child_type if t != "null"), child_type[0])
nested_props = child.get("properties") nested_props = child.get("properties")
if (child_type == "object" or nested_props) and isinstance(nested_props, dict): if (child_type == "object" or nested_props) and isinstance(nested_props, dict):
fields.extend( fields.extend(flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1))
flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1)
)
elif child_type == "array": elif child_type == "array":
items = child.get("items") items = child.get("items")
if isinstance(items, dict) and ( if isinstance(items, dict) and (
@@ -111,9 +109,7 @@ def flatten_schema_fields(
): ):
# Expand one sample element so nested array object fields appear. # Expand one sample element so nested array object fields appear.
fields.extend( fields.extend(
flatten_schema_fields( flatten_schema_fields(items, prefix=f"{path}.0", max_depth=max_depth - 1)
items, prefix=f"{path}.0", max_depth=max_depth - 1
)
) )
else: else:
fields.append( fields.append(
@@ -231,8 +227,10 @@ def unflatten_body(values: dict[str, Any]) -> dict[str, Any]:
return return
while len(cur) <= idx: while len(cur) <= idx:
cur.append([] if want_array else {}) cur.append([] if want_array else {})
if cur[idx] is None or (want_array and not isinstance(cur[idx], list)) or ( if (
not want_array and not isinstance(cur[idx], dict) 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[idx] = [] if want_array else {}
cur = cur[idx] cur = cur[idx]
+71 -1
View File
@@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Annotated, Any from typing import Annotated, Any
from uuid import uuid4 from uuid import NAMESPACE_URL, uuid4, uuid5
from asyncpg import Connection from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
@@ -14,6 +15,16 @@ from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Cinder"]) 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]: def _volume(row: Any) -> dict[str, Any]:
return { 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")
@router.get("/v3/{project_id}/volumes/detail") @router.get("/v3/{project_id}/volumes/detail")
@router.get("/v3/volumes") @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]} return {"aggregates": [_aggregate_dict(r) for r in rows]}
@router.get("/v2.1/os-aggregates/{aggregate_id}") @router.post("/v2.1/os-aggregates", status_code=201)
async def show_aggregate( async def create_aggregate(
aggregate_id: str, request: Request,
conn: Annotated[Connection, Depends(get_conn)], conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)], _ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]: ) -> 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( row = await conn.fetchrow(
"""SELECT * FROM os_aggregates """SELECT * FROM os_aggregates
WHERE id::text=$1 OR name=$1 WHERE id::text=$1 OR name=$1
@@ -943,7 +967,106 @@ async def show_aggregate(
) )
if row is None: if row is None:
raise OpenStackError("NotFound", f"aggregate {aggregate_id} not found", status_code=404) 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") @router.get("/v2.1/os-services")
@@ -1559,6 +1682,33 @@ async def delete_server_tag(
return Response(status_code=204) 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") @router.get("/v2.1/servers/{server_id}/os-security-groups")
async def server_security_groups( async def server_security_groups(
server_id: str, server_id: str,
@@ -1570,13 +1720,76 @@ async def server_security_groups(
"SELECT * FROM os_security_groups WHERE project_id=$1 ORDER BY name", "SELECT * FROM os_security_groups WHERE project_id=$1 ORDER BY name",
ctx.project_id, 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 { return {
"security_groups": [ "security_group": {
{"id": str(r["id"]), "name": r["name"], "description": r["description"]} for r in rows "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}") @router.get("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
async def show_server_security_group( async def show_server_security_group(
server_id: str, server_id: str,
@@ -1585,26 +1798,66 @@ async def show_server_security_group(
ctx: Annotated[TokenContext, Depends(require_project_token)], ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]: ) -> dict[str, object]:
_ = server_id _ = server_id
row = await conn.fetchrow( return await _show_server_sg_impl(conn, ctx.project_id, security_group_id)
"""SELECT * FROM os_security_groups
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
LIMIT 1""", @router.get("/v2.1/servers/{server_id}/os-security-groups/{id}")
ctx.project_id, async def show_server_security_group_by_id(
security_group_id, server_id: str,
) id: str,
if row is None: conn: Annotated[Connection, Depends(get_conn)],
raise OpenStackError( ctx: Annotated[TokenContext, Depends(require_project_token)],
"NotFound", ) -> dict[str, object]:
f"security_group {security_group_id} not found", _ = server_id
status_code=404, return await _show_server_sg_impl(conn, ctx.project_id, id)
)
return {
"security_group": { @router.put("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
"id": str(row["id"]), @router.patch("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
"name": row["name"], async def update_server_security_group(
"description": row["description"], 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") @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", "nova",
"console_auth_token_defaults", "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) base = service_base_url(host, pack.name, port=pack.port)
url = f"{base}{path}" url = f"{base}{path}"
method = (method_override or op.method).upper() 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) mv = microversion_headers(pack, op)
status, payload = http_request( status, payload = http_request(
method, method,
@@ -886,11 +890,7 @@ def probe_series_lifecycle(
local["server_id"] = rid local["server_id"] = rid
# Swift: empty the container before DELETE so the API returns 204 # Swift: empty the container before DELETE so the API returns 204
# (409 Conflict for a non-empty container is correct OpenStack behaviour). # (409 Conflict for a non-empty container is correct OpenStack behaviour).
if ( if op.method == "DELETE" and pack.name == "swift" and op.resource_type == "container":
op.method == "DELETE"
and pack.name == "swift"
and op.resource_type == "container"
):
acct = local.get("account") or project_id acct = local.get("account") or project_id
cname = local.get("container") or local.get("id") cname = local.get("container") or local.get("id")
if acct and cname: if acct and cname:
+1 -1
View File
@@ -18,7 +18,7 @@ header on each page. Russian mirrors live under [`ru/`](ru/README.md).
| [Seed profiles](seed-profiles.md) | `minimal` / `demo` | | [Seed profiles](seed-profiles.md) | `minimal` / `demo` |
| [Clients](clients.md) | SDK / CLI | | [Clients](clients.md) | SDK / CLI |
| [Web UI](web-ui.md) | Console drawers | | [Web UI](web-ui.md) | Console drawers |
| [Operations](operations.md) | Day-2, release, reseed | | [Operations](operations.md) | Day-2, release, reseed, **testing** |
| [Architecture](architecture.md) | Components & request path | | [Architecture](architecture.md) | Components & request path |
| [Security](security.md) | Lab threat model | | [Security](security.md) | Lab threat model |
| [Observability](observability.md) | Health & logs | | [Observability](observability.md) | Health & logs |
+15
View File
@@ -31,3 +31,18 @@ open reports/pulumi-report.html
5. Destroy stack; emit HTML + JUnit 5. Destroy stack; emit HTML + JUnit
See [`pulumi-tests/README.md`](../pulumi-tests/README.md). See [`pulumi-tests/README.md`](../pulumi-tests/README.md).
Broader pytest / smoke map: [operations.md — Testing](operations.md#testing).
## Latest results (2026-07-18)
Full suite (`make pulumi-tests`, `collections_only=false`):
| Series | HTTP ok / total | Pulumi |
|---|---:|---|
| yoga | 1464 / 1464 | ok |
| antelope | 1530 / 1530 | ok |
| caracal | 1649 / 1649 | ok |
| dalmatian | 1871 / 1871 | ok |
| **All** | **6514 / 6514** (`http_critical=0`) | **4 / 4** |
Artifacts: `pulumi-tests/reports/pulumi-report.html`, `summary.json`.
+30 -6
View File
@@ -112,17 +112,41 @@ make release-build # local tags only
See [kubernetes.md](kubernetes.md) for logs, reseed via `kubectl exec`, and See [kubernetes.md](kubernetes.md) for logs, reseed via `kubectl exec`, and
uninstall. uninstall.
## API coverage lab CI (pulumi-tests) ## Testing
Pulumi probes every pack operation across series — see | Location | What |
[hypervisor-lab.md](hypervisor-lab.md). |---|---|
| `tests/unit/` | Offline unit tests (ASGI / FakeDatabase) |
| `tests/openstack/` | Pack contracts, registry, live surface / lifecycle |
| `tests/integration/` | Postgres-backed integration |
| `tests/compatibility/` | Surface probe + group smoke markers |
| `examples/python/openstack_smoke.py` | Host multi-port smoke (`make smoke` / `make test-compatibility`) |
| `examples/python/openstack_surface_probe.py` | Lifecycle probe every pack op × series |
| [`pulumi-tests/`](../pulumi-tests/README.md) | Pulumi Layer B + full HTTP matrix (yoga→dalmatian) |
```bash ```bash
make test-pulumi-smoke # from repo root make test # offline unit + contract (no Postgres)
make test-pulumi make test-integration # -m integration (Postgres)
make test-surface # surface probe + tests/openstack against Compose
make test-compatibility # seed minimal + openstack_smoke.py
make pulumi-tests # full pulumi-tests suite (alias: make test-pulumi)
make test-pulumi-smoke # fast collection GET + HEAD only
``` ```
Reports: `pulumi-tests/reports/pulumi-report.html` and `pulumi-junit.xml`. Details for the Pulumi lab: [hypervisor-lab.md](hypervisor-lab.md).
Reports: `pulumi-tests/reports/pulumi-report.html`, `pulumi-junit.xml`, `summary.json`.
### Latest lab results (2026-07-18)
| Suite | Result |
|---|---|
| `make test` | 243 passed (34 deselected) |
| `make test-integration` | 33 passed |
| Surface probe (yoga→dalmatian) | 1464 / 1530 / 1649 / 1871 ops — **0 fail** |
| `make pulumi-tests` (full matrix) | HTTP **6514 / 6514**, `http_critical=0`, pulumi **4 / 4** series |
| Compatibility smoke | OK (auto Keystone `:5000` or local `:15000`) |
Regenerate the HTML report anytime with `make -C pulumi-tests report` after a suite run.
## Upgrades ## Upgrades
+1 -1
View File
@@ -19,7 +19,7 @@
| [Seed-профили](seed-profiles.md) | `minimal` / `demo` | | [Seed-профили](seed-profiles.md) | `minimal` / `demo` |
| [Клиенты](clients.md) | SDK / CLI | | [Клиенты](clients.md) | SDK / CLI |
| [Web UI](web-ui.md) | Консоль и drawers | | [Web UI](web-ui.md) | Консоль и drawers |
| [Эксплуатация](operations.md) | Day-2, релиз, reseed | | [Эксплуатация](operations.md) | Day-2, релиз, reseed, **тесты** |
| [Архитектура](architecture.md) | Компоненты и путь запроса | | [Архитектура](architecture.md) | Компоненты и путь запроса |
| [Безопасность](security.md) | Threat model лаборатории | | [Безопасность](security.md) | Threat model лаборатории |
| [Наблюдаемость](observability.md) | Health и логи | | [Наблюдаемость](observability.md) | Health и логи |
+15
View File
@@ -31,3 +31,18 @@ open reports/pulumi-report.html
5. Destroy; HTML + JUnit 5. Destroy; HTML + JUnit
См. [`pulumi-tests/README.ru.md`](../../pulumi-tests/README.ru.md). См. [`pulumi-tests/README.ru.md`](../../pulumi-tests/README.ru.md).
Карта pytest / smoke: [operations.md — Тестирование](operations.md#тестирование).
## Последние результаты (2026-07-18)
Полный сьют (`make pulumi-tests`, `collections_only=false`):
| Серия | HTTP ok / total | Pulumi |
|---|---:|---|
| yoga | 1464 / 1464 | ok |
| antelope | 1530 / 1530 | ok |
| caracal | 1649 / 1649 | ok |
| dalmatian | 1871 / 1871 | ok |
| **Все** | **6514 / 6514** (`http_critical=0`) | **4 / 4** |
Артефакты: `pulumi-tests/reports/pulumi-report.html`, `summary.json`.
+30 -6
View File
@@ -112,17 +112,41 @@ make release-build # local tags only
См. [kubernetes.md](kubernetes.md) для логов, reseed через `kubectl exec` и См. [kubernetes.md](kubernetes.md) для логов, reseed через `kubectl exec` и
удаления. удаления.
## CI лаборатории покрытия API (pulumi-tests) ## Тестирование
Pulumi прогоняет каждую pack-операцию по сериям — см. | Где | Что |
[hypervisor-lab.md](hypervisor-lab.md). |---|---|
| `tests/unit/` | Офлайн unit (ASGI / FakeDatabase) |
| `tests/openstack/` | Pack-контракты, registry, live surface / lifecycle |
| `tests/integration/` | Интеграция с PostgreSQL |
| `tests/compatibility/` | Surface probe и group smoke |
| `examples/python/openstack_smoke.py` | Host multi-port smoke (`make smoke` / `make test-compatibility`) |
| `examples/python/openstack_surface_probe.py` | Lifecycle-probe каждой pack-операции × серии |
| [`pulumi-tests/`](../../pulumi-tests/README.ru.md) | Pulumi Layer B + полная HTTP-матрица (yoga→dalmatian) |
```bash ```bash
make test-pulumi-smoke # from repo root make test # офлайн unit + contract (без Postgres)
make test-pulumi make test-integration # -m integration (Postgres)
make test-surface # surface probe + tests/openstack против Compose
make test-compatibility # seed minimal + openstack_smoke.py
make pulumi-tests # полный сьют pulumi-tests (alias: make test-pulumi)
make test-pulumi-smoke # быстрый режим: collection GET + HEAD
``` ```
Отчёты: `pulumi-tests/reports/pulumi-report.html` и `pulumi-junit.xml`. Подробнее о Pulumi-лаборатории: [hypervisor-lab.md](hypervisor-lab.md).
Отчёты: `pulumi-tests/reports/pulumi-report.html`, `pulumi-junit.xml`, `summary.json`.
### Последние результаты лаборатории (2026-07-18)
| Сьют | Результат |
|---|---|
| `make test` | 243 passed (34 deselected) |
| `make test-integration` | 33 passed |
| Surface probe (yoga→dalmatian) | 1464 / 1530 / 1649 / 1871 ops — **0 fail** |
| `make pulumi-tests` (полная matrix) | HTTP **6514 / 6514**, `http_critical=0`, pulumi **4 / 4** серии |
| Compatibility smoke | OK (авто Keystone `:5000` или локальный `:15000`) |
Пересобрать HTML-отчёт: `make -C pulumi-tests report` после прогона.
## Обновления ## Обновления
+55 -36
View File
@@ -10,13 +10,60 @@ import urllib.error
import urllib.request import urllib.request
HOST = os.environ.get("OS_HOST", "127.0.0.1") HOST = os.environ.get("OS_HOST", "127.0.0.1")
KEYSTONE = sys.argv[1] if len(sys.argv) > 1 else f"http://{HOST}:5000"
def _u(port: int, path: str) -> str: def _u(port: int, path: str) -> str:
return f"http://{HOST}:{port}{path}" return f"http://{HOST}:{port}{path}"
def request(
method: str,
url: str,
*,
data: dict | None = None,
token: str | None = None,
extra_headers: dict[str, str] | None = None,
):
body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as res:
raw = res.read().decode()
return res.status, dict(res.headers), json.loads(raw) if raw else None
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return exc.code, dict(exc.headers), parsed
except urllib.error.URLError as exc:
return 0, {}, {"error": str(exc.reason)}
def _keystone_base() -> str:
if len(sys.argv) > 1:
return sys.argv[1].rstrip("/")
for key in ("OS_AUTH_URL", "OS_GATEWAY_URL"):
raw = (os.environ.get(key) or "").strip()
if raw:
return raw.rstrip("/").removesuffix("/v3")
# Prefer :5000; fall back to local Compose override (:15000) when AirPlay owns 5000.
for port in (5000, 15000):
url = f"http://{HOST}:{port}"
status, _, _ = request("GET", f"{url}/health/ready")
if status == 200:
return url
return f"http://{HOST}:5000"
# (label, url, expected_json_key or None for version-only) # (label, url, expected_json_key or None for version-only)
CHECKS: list[tuple[str, str, str | None]] = [ CHECKS: list[tuple[str, str, str | None]] = [
("nova.servers", _u(8774, "/v2.1/servers/detail"), "servers"), ("nova.servers", _u(8774, "/v2.1/servers/detail"), "servers"),
@@ -54,39 +101,8 @@ CHECKS: list[tuple[str, str, str | None]] = [
] ]
def request(
method: str,
url: str,
*,
data: dict | None = None,
token: str | None = None,
extra_headers: dict[str, str] | None = None,
):
body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as res:
raw = res.read().decode()
return res.status, dict(res.headers), json.loads(raw) if raw else None
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return exc.code, dict(exc.headers), parsed
except urllib.error.URLError as exc:
return 0, {}, {"error": str(exc.reason)}
def main() -> int: def main() -> int:
keystone = _keystone_base()
auth = { auth = {
"auth": { "auth": {
"identity": { "identity": {
@@ -102,7 +118,7 @@ def main() -> int:
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}}, "scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
} }
} }
status, headers, body = request("POST", f"{KEYSTONE}/v3/auth/tokens", data=auth) status, headers, body = request("POST", f"{keystone}/v3/auth/tokens", data=auth)
token = headers.get("X-Subject-Token") or headers.get("x-subject-token") token = headers.get("X-Subject-Token") or headers.get("x-subject-token")
print("auth", status, "token", bool(token)) print("auth", status, "token", bool(token))
if status != 201 or not token: if status != 201 or not token:
@@ -145,8 +161,11 @@ def main() -> int:
print(" FAIL", payload) print(" FAIL", payload)
failed += 1 failed += 1
# Root discovery per port # Root discovery per port (Keystone may be remapped, e.g. host :15000).
for port, name in [(5000, "keystone"), (8774, "nova"), (6385, "ironic"), (8080, "swift")]: from urllib.parse import urlparse
ks_port = urlparse(keystone).port or 5000
for port, name in [(ks_port, "keystone"), (8774, "nova"), (6385, "ironic"), (8080, "swift")]:
st, _, payload = request("GET", _u(port, "/")) st, _, payload = request("GET", _u(port, "/"))
print(f"root.{name}", st, list((payload or {}).keys())[:3]) print(f"root.{name}", st, list((payload or {}).keys())[:3])
+15
View File
@@ -55,6 +55,21 @@ Pack sizes (ops, without HEAD): yoga ~1060 → antelope ~1108 → caracal ~1196
| `reports/series-<name>.json` | Per-series pulumi + HTTP details | | `reports/series-<name>.json` | Per-series pulumi + HTTP details |
| `reports/summary.json` | Aggregates (`http_total` / `http_expected`, `http_critical`) | | `reports/summary.json` | Aggregates (`http_total` / `http_expected`, `http_critical`) |
## Latest results (2026-07-18)
Full matrix (`make test-pulumi` / `make pulumi-tests`):
| Metric | Value |
|---|---|
| Series | yoga → dalmatian (4) |
| `pulumi_ok` | 4 / 4 |
| HTTP probed | **6514 / 6514** (`declared` 4721 + synthetic `HEAD` 1793) |
| `http_critical` | **0** |
| Per series | yoga 1464 · antelope 1530 · caracal 1649 · dalmatian 1871 |
Open `reports/pulumi-report.html` after a run. Pytest / smoke locations for the
whole repo: [docs/operations.md — Testing](../docs/operations.md#testing).
## Approximate (not blockers) ## Approximate (not blockers)
Nova create may skip a long `BUILD` window; many Nova actions only flip server Nova create may skip a long `BUILD` window; many Nova actions only flip server
+15
View File
@@ -55,6 +55,21 @@ open reports/pulumi-report.html
| `reports/series-<name>.json` | Детали pulumi + HTTP по серии | | `reports/series-<name>.json` | Детали pulumi + HTTP по серии |
| `reports/summary.json` | Агрегаты (`http_total` / `http_expected`, `http_critical`) | | `reports/summary.json` | Агрегаты (`http_total` / `http_expected`, `http_critical`) |
## Последние результаты (2026-07-18)
Полная matrix (`make test-pulumi` / `make pulumi-tests`):
| Метрика | Значение |
|---|---|
| Серии | yoga → dalmatian (4) |
| `pulumi_ok` | 4 / 4 |
| HTTP probed | **6514 / 6514** (`declared` 4721 + синтетический `HEAD` 1793) |
| `http_critical` | **0** |
| По сериям | yoga 1464 · antelope 1530 · caracal 1649 · dalmatian 1871 |
После прогона: `reports/pulumi-report.html`. Где лежат остальные тесты репозитория:
[docs/ru/operations.md — Тестирование](../docs/ru/operations.md#тестирование).
## Approximate (не блокеры) ## Approximate (не блокеры)
Nova create может пропускать длинное окно `BUILD`; многие Nova actions только Nova create может пропускать длинное окно `BUILD`; многие Nova actions только
+1 -1
View File
@@ -21,7 +21,7 @@
<body> <body>
<header> <header>
<h1>Pulumi OpenStack API coverage</h1> <h1>Pulumi OpenStack API coverage</h1>
<p class="muted">Generated 2026-07-18 03:50:29 UTC · <strong>100% = HTTP contract matrix</strong> (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.</p> <p class="muted">Generated 2026-07-18 06:30:32 UTC · <strong>100% = HTTP contract matrix</strong> (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.</p>
</header> </header>
<main> <main>
<div class="summary"> <div class="summary">
+21 -21
View File
@@ -2,35 +2,35 @@
"series": "antelope", "series": "antelope",
"pulumi": { "pulumi": {
"series": "antelope", "series": "antelope",
"elapsed_s": 15.55, "elapsed_s": 14.74,
"error": null, "error": null,
"outputs": { "outputs": {
"auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581",
"auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb",
"created_project_id": "636996ce-af86-4a46-803a-d00392971169", "created_project_id": "1c910aae-e27d-4aac-81c3-22a830c0ba66",
"created_user_id": "efe09170-ca36-4285-b3a4-025a3002a480", "created_user_id": "0ef2bed4-d3de-4e3b-bd81-63170d4500bd",
"demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98",
"flavor_id": "1", "flavor_id": "1",
"image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803",
"image_name": "cirros", "image_name": "cirros",
"interface_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689/ade9bfa2-3134-45dc-ab23-d622e4e68e5c", "interface_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9/5148e3ed-e330-4c07-bf7f-b3df0c5e58a4",
"keypair_name": "pu-antelope-bf6ba4-kp", "keypair_name": "pu-antelope-57dc3f-kp",
"network_id": "13af915a-7f32-4b03-bba3-cef4e3be9ac9", "network_id": "73f455da-3298-4d24-ad8f-05818041032a",
"port_id": "ade9bfa2-3134-45dc-ab23-d622e4e68e5c", "port_id": "5148e3ed-e330-4c07-bf7f-b3df0c5e58a4",
"project_name": "demo", "project_name": "demo",
"router_id": "e64d25a6-43f6-40d8-a3fe-b80c6768603a", "router_id": "2b01bee5-74f0-42db-a3b3-7384bfde574f",
"router_iface_id": "ba04886a-aff6-464a-ba16-dd067ce6e1dd", "router_iface_id": "52fa67db-1688-4f46-ac02-c6fd51ba7885",
"secgroup_id": "b45ef742-cd84-45f3-92a6-6379facbc10b", "secgroup_id": "7ede16f0-073c-4265-a727-bfd361888cd6",
"secgroup_rule_id": "ed4ab26d-9d9d-4a08-a6db-261ed034e01d", "secgroup_rule_id": "7c334d3d-e7d8-40f3-82a5-2f8d24de2e4a",
"series": "antelope", "series": "antelope",
"server_group_id": "fbea9f9d-2dc6-485d-ab96-e143525fb781", "server_group_id": "1a03413b-9650-445a-a5be-f1e126cf6b57",
"server_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689", "server_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9",
"server_name": "pu-antelope-bf6ba4-vm", "server_name": "pu-antelope-57dc3f-vm",
"subnet_id": "46f11ac5-cfe7-4a70-ab29-6ee9e000f8ea", "subnet_id": "7039ff93-83f0-4105-b24c-727eaa6a9951",
"tag": "pu-antelope-bf6ba4", "tag": "pu-antelope-57dc3f",
"volume2_id": "e6d48c67-e79c-4db1-b3a6-8b62a3540a2a", "volume2_id": "d64eff18-fcd8-4102-9a0d-467aab6caf40",
"volume_attach_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689/7066859a-cf37-4baf-b969-4b2cea586d34", "volume_attach_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9/07e8ed96-ddc2-43a6-bb20-7480194c7669",
"volume_id": "a3e4eedd-dc55-4999-b1a9-714f8dfa2c28" "volume_id": "e427eb9d-87f3-44a6-a80f-fc05730e6f2e"
}, },
"empty_exports": [], "empty_exports": [],
"ok": true "ok": true
@@ -13053,11 +13053,11 @@
"method": "HEAD", "method": "HEAD",
"path": "/v3/os-quota-sets/{id}", "path": "/v3/os-quota-sets/{id}",
"operation_id": "cinder_quota_show__head", "operation_id": "cinder_quota_show__head",
"status": 404, "status": 200,
"detail": "", "detail": "",
"mode": "head", "mode": "head",
"ok": true, "ok": true,
"succeeded": false "succeeded": true
}, },
{ {
"service": "cinder", "service": "cinder",
+21 -21
View File
@@ -2,35 +2,35 @@
"series": "caracal", "series": "caracal",
"pulumi": { "pulumi": {
"series": "caracal", "series": "caracal",
"elapsed_s": 13.94, "elapsed_s": 17.38,
"error": null, "error": null,
"outputs": { "outputs": {
"auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581",
"auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb",
"created_project_id": "4ccc6157-9838-41ed-b609-6fad16c7485b", "created_project_id": "31415a09-9cde-45d6-a021-44898133fbe7",
"created_user_id": "eb2db72c-1204-40ed-8b34-e099a42a9ece", "created_user_id": "66a94333-475c-4f96-98ec-690f556fac62",
"demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98",
"flavor_id": "1", "flavor_id": "1",
"image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803",
"image_name": "cirros", "image_name": "cirros",
"interface_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e/3a302d32-d14f-4a89-83a4-377b8400dd64", "interface_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa/4f61a1dc-d739-4b52-861b-945926f7bc60",
"keypair_name": "pu-caracal-913e05-kp", "keypair_name": "pu-caracal-079211-kp",
"network_id": "d87e3ba7-88fd-439e-a700-f8c904c5962c", "network_id": "bc261b59-7e65-4f19-94b2-f8736a3d07a9",
"port_id": "3a302d32-d14f-4a89-83a4-377b8400dd64", "port_id": "4f61a1dc-d739-4b52-861b-945926f7bc60",
"project_name": "demo", "project_name": "demo",
"router_id": "dbd5921f-f7af-491d-8b6c-7a108e4dee02", "router_id": "f8610281-2117-4175-833f-2d532783c6b7",
"router_iface_id": "6dc250c1-3718-4785-a020-eef0f89438cb", "router_iface_id": "2c88a159-45b3-4239-9d17-dd4a7a3e438f",
"secgroup_id": "e84c344b-7f7a-4b30-bf40-6f6a176f288e", "secgroup_id": "b2dd227c-7000-4545-82b8-d8ddd7b97e43",
"secgroup_rule_id": "ae2be8f8-1c5f-4ebf-81ae-1c78b79089d5", "secgroup_rule_id": "c12cd8fe-531e-4ef0-9401-6a60b86f871a",
"series": "caracal", "series": "caracal",
"server_group_id": "6dc9fd5e-5ecf-4289-bd1a-f2a0937cb8e9", "server_group_id": "b738e0cb-eb50-41d4-b466-5d594588c52e",
"server_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e", "server_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa",
"server_name": "pu-caracal-913e05-vm", "server_name": "pu-caracal-079211-vm",
"subnet_id": "fe94e98e-febd-4348-8e23-b99a04733066", "subnet_id": "0888b0b9-00da-4d67-a70d-b28503d1361f",
"tag": "pu-caracal-913e05", "tag": "pu-caracal-079211",
"volume2_id": "79e1cc1c-d0f0-4a63-ad0e-e8be2dc016c0", "volume2_id": "eb595372-9882-4553-bc0c-c266784d1d2f",
"volume_attach_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e/ef4b5b3d-6518-4959-922a-c717c54f57a9", "volume_attach_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa/b781c110-61ea-4c64-9697-7b7baa5e1d5c",
"volume_id": "1b947b41-3442-475c-817a-9be42362a2d5" "volume_id": "43736cfa-2cb8-400a-ad79-88346c437cc3"
}, },
"empty_exports": [], "empty_exports": [],
"ok": true "ok": true
@@ -14021,11 +14021,11 @@
"method": "HEAD", "method": "HEAD",
"path": "/v3/os-quota-sets/{id}", "path": "/v3/os-quota-sets/{id}",
"operation_id": "cinder_quota_show__head", "operation_id": "cinder_quota_show__head",
"status": 404, "status": 200,
"detail": "", "detail": "",
"mode": "head", "mode": "head",
"ok": true, "ok": true,
"succeeded": false "succeeded": true
}, },
{ {
"service": "cinder", "service": "cinder",
+21 -21
View File
@@ -2,35 +2,35 @@
"series": "dalmatian", "series": "dalmatian",
"pulumi": { "pulumi": {
"series": "dalmatian", "series": "dalmatian",
"elapsed_s": 12.89, "elapsed_s": 11.82,
"error": null, "error": null,
"outputs": { "outputs": {
"auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581",
"auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb",
"created_project_id": "3703536d-efea-4bb1-866d-c76a3742e2b9", "created_project_id": "723a139f-e15b-4971-a062-4824228ec02b",
"created_user_id": "97347426-e13c-4c1e-8d48-4b4f12d785ae", "created_user_id": "6c8b9175-3885-4dbf-931e-59aca29579fc",
"demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98",
"flavor_id": "1", "flavor_id": "1",
"image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803",
"image_name": "cirros", "image_name": "cirros",
"interface_id": "21bffe97-64d0-4b27-9711-dc91a405426c/ae9a0ea7-8438-410d-be4e-19508d097277", "interface_id": "643a3d90-729e-47c4-91be-2d71e048cc8c/9bdada09-684c-49b2-9bbf-e067153740f7",
"keypair_name": "pu-dalmatian-70f98d-kp", "keypair_name": "pu-dalmatian-2caef0-kp",
"network_id": "c618bb28-a49e-4276-9839-41cbbd5f50bd", "network_id": "636d541b-b4f8-434f-a2a5-f8432870ee09",
"port_id": "ae9a0ea7-8438-410d-be4e-19508d097277", "port_id": "9bdada09-684c-49b2-9bbf-e067153740f7",
"project_name": "demo", "project_name": "demo",
"router_id": "7803044e-f510-4876-bc9f-20a9366b3251", "router_id": "1de5abd6-3948-44e1-b009-547f8a390864",
"router_iface_id": "b45e4712-be52-4621-be82-28ed23f8883c", "router_iface_id": "3b94297d-fd08-4486-95eb-136f217a1be6",
"secgroup_id": "329d3bd1-15b8-41ed-aeb4-06a88f5d90dd", "secgroup_id": "1e30a30c-ddca-467d-a4d5-a4edb479d7f3",
"secgroup_rule_id": "06d66321-c89a-4c2f-9323-d5508c8c7677", "secgroup_rule_id": "7689db06-1c02-4c33-b252-705984aa15d6",
"series": "dalmatian", "series": "dalmatian",
"server_group_id": "d4ae5792-335f-43ac-bdf0-2422541bace9", "server_group_id": "53da92a5-9b9e-481c-a813-2445f3eecf21",
"server_id": "21bffe97-64d0-4b27-9711-dc91a405426c", "server_id": "643a3d90-729e-47c4-91be-2d71e048cc8c",
"server_name": "pu-dalmatian-70f98d-vm", "server_name": "pu-dalmatian-2caef0-vm",
"subnet_id": "f5b4fb63-f8a3-4309-861e-4d13b3863c19", "subnet_id": "92350576-ad17-4fd6-b811-5719d3d18b3c",
"tag": "pu-dalmatian-70f98d", "tag": "pu-dalmatian-2caef0",
"volume2_id": "3adc3ced-c25c-46bd-8786-c00737cf9ac6", "volume2_id": "5361c5ea-8607-44ff-b4de-52af293137c1",
"volume_attach_id": "21bffe97-64d0-4b27-9711-dc91a405426c/60b5be58-d7d8-4d46-8e04-351c19ee435d", "volume_attach_id": "643a3d90-729e-47c4-91be-2d71e048cc8c/af788c02-dff0-4ef4-a736-aa385f2de7cf",
"volume_id": "22d3de1c-3f24-4bdc-bc79-cf4b1a47bd82" "volume_id": "d0eb45de-d29a-45af-948f-7e459f623225"
}, },
"empty_exports": [], "empty_exports": [],
"ok": true "ok": true
@@ -15792,11 +15792,11 @@
"method": "HEAD", "method": "HEAD",
"path": "/v3/os-quota-sets/{id}", "path": "/v3/os-quota-sets/{id}",
"operation_id": "cinder_quota_show__head", "operation_id": "cinder_quota_show__head",
"status": 404, "status": 200,
"detail": "", "detail": "",
"mode": "head", "mode": "head",
"ok": true, "ok": true,
"succeeded": false "succeeded": true
}, },
{ {
"service": "cinder", "service": "cinder",
+21 -21
View File
@@ -2,35 +2,35 @@
"series": "yoga", "series": "yoga",
"pulumi": { "pulumi": {
"series": "yoga", "series": "yoga",
"elapsed_s": 21.05, "elapsed_s": 28.01,
"error": null, "error": null,
"outputs": { "outputs": {
"auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581",
"auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb",
"created_project_id": "a719f057-c4e2-4e22-9959-d236923a8ede", "created_project_id": "7420191b-3936-4857-b7e9-eecc8ec7f551",
"created_user_id": "32a0fb8d-4a4a-4dd4-b146-c145ffb19715", "created_user_id": "7d4e7f29-6d27-453c-a915-8f2159d79205",
"demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98",
"flavor_id": "1", "flavor_id": "1",
"image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803",
"image_name": "cirros", "image_name": "cirros",
"interface_id": "6997d545-7aed-4b1f-b83f-01746593bd83/65b607b4-13bb-4635-9510-041b2d9db881", "interface_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703/7664e368-1589-4efa-a7d2-d84cde7642a9",
"keypair_name": "pu-yoga-f8ae3f-kp", "keypair_name": "pu-yoga-ec8080-kp",
"network_id": "5e2a0c44-766b-4154-8a80-0bcee1d3b73e", "network_id": "8c2ac66a-6558-4a29-8daf-5cea581ac4ba",
"port_id": "65b607b4-13bb-4635-9510-041b2d9db881", "port_id": "7664e368-1589-4efa-a7d2-d84cde7642a9",
"project_name": "demo", "project_name": "demo",
"router_id": "7a57a2dd-7e47-40af-9966-f199dd114fc0", "router_id": "95b514eb-b5d2-4781-a567-8e164f28f156",
"router_iface_id": "2a600c25-898c-4444-92f3-698d9faabc86", "router_iface_id": "c144876c-8b2b-4534-900a-1bec13965bba",
"secgroup_id": "59a4612f-754e-4dd4-ad82-cafc05dd8d1a", "secgroup_id": "f173a8a9-f399-4484-a98f-729f7b7a1a52",
"secgroup_rule_id": "cb4a9464-d9f8-4b96-a7f3-00c0a463586d", "secgroup_rule_id": "dd83a9ad-aa94-45c5-bf21-828fb04732b6",
"series": "yoga", "series": "yoga",
"server_group_id": "d2b1cd50-ba00-4b6e-b2dc-dae7d32eac18", "server_group_id": "6a089d83-f4d7-4947-aa56-f8720160ae78",
"server_id": "6997d545-7aed-4b1f-b83f-01746593bd83", "server_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703",
"server_name": "pu-yoga-f8ae3f-vm", "server_name": "pu-yoga-ec8080-vm",
"subnet_id": "ecab7de1-6c80-4904-9676-f95eb84c84ca", "subnet_id": "295c6526-bd1b-4d17-a274-1e06675f4212",
"tag": "pu-yoga-f8ae3f", "tag": "pu-yoga-ec8080",
"volume2_id": "ebe99f6c-9148-4079-b6cd-30cda1337731", "volume2_id": "954ccc29-ccb6-40af-b524-9a26287782e8",
"volume_attach_id": "6997d545-7aed-4b1f-b83f-01746593bd83/95982862-b32d-4f22-b29c-b01d8c7d93ad", "volume_attach_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703/d794d477-c4f1-4ac9-8904-92f45375cbd2",
"volume_id": "0cdaed66-20e7-4191-8410-b865caffce81" "volume_id": "d5709565-755d-4f74-8270-62e0ecef85f5"
}, },
"empty_exports": [], "empty_exports": [],
"ok": true "ok": true
@@ -12525,11 +12525,11 @@
"method": "HEAD", "method": "HEAD",
"path": "/v3/os-quota-sets/{id}", "path": "/v3/os-quota-sets/{id}",
"operation_id": "cinder_quota_show__head", "operation_id": "cinder_quota_show__head",
"status": 404, "status": 200,
"detail": "", "detail": "",
"mode": "head", "mode": "head",
"ok": true, "ok": true,
"succeeded": false "succeeded": true
}, },
{ {
"service": "cinder", "service": "cinder",
+15 -24
View File
@@ -1,10 +1,8 @@
"""Catalog-scoped compatibility payload tests.""" """Catalog-scoped compatibility payload tests."""
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import cast from typing import cast
import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.compatibility import CompatibilityDimension, build_report from app.compatibility import CompatibilityDimension, build_report
@@ -14,13 +12,6 @@ from app.main import create_app
from app.web.compatibility_catalog import compatibility_payload from app.web.compatibility_catalog import compatibility_payload
from tests.unit.test_health import FakeDatabase from tests.unit.test_health import FakeDatabase
_BUNDLED = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_PVE7 = Path(
"contracts/2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f/snapshot.json"
)
def _snapshot(source_version: str, path: str) -> Snapshot: def _snapshot(source_version: str, path: str) -> Snapshot:
method = Method( method = Method(
@@ -83,11 +74,8 @@ def test_catalog_compatibility_reuses_runtime_report_for_matching_version() -> N
async def test_ui_compatibility_endpoint_follows_selected_major() -> None: async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
if not _PVE7.is_file():
pytest.skip("PVE 7 bundled contract is unavailable")
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app( app = create_app(
settings=settings, settings=Settings(contract_snapshot=None),
database_factory=lambda _settings: FakeDatabase(True), database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(), worker_factories=(),
) )
@@ -99,30 +87,33 @@ async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
assert major9.status_code == 200 assert major9.status_code == 200
body7 = major7.json() body7 = major7.json()
body9 = major9.json() body9 = major9.json()
pve7_snapshot = Snapshot.model_validate_json(_PVE7.read_bytes()) assert body7["catalog_version"] == "openstack-antelope"
assert body7["catalog_version"] == pve7_snapshot.source_version assert body9["catalog_version"] == "openstack-dalmatian"
assert body9["catalog_version"] == "9.2.3" assert body7["total_declared"] >= 1100
assert body7["total_declared"] == pve7_snapshot.method_count assert body9["total_declared"] >= 1300
bundled = Snapshot.model_validate_json(_BUNDLED.read_bytes())
assert body9["total_declared"] == bundled.method_count
assert body7["major"] == 7 assert body7["major"] == 7
assert body9["major"] == 9 assert body9["major"] == 9
# Legacy aliases are kept in implemented_methods so older majors report full coverage.
assert body7["levels"]["implemented"]["count"] == body7["total_declared"] assert body7["levels"]["implemented"]["count"] == body7["total_declared"]
assert body9["levels"]["implemented"]["count"] == body9["total_declared"] assert body9["levels"]["implemented"]["count"] == body9["total_declared"]
async def test_ui_compatibility_covers_all_bundled_majors() -> None: async def test_ui_compatibility_covers_all_openstack_series() -> None:
settings = Settings(contract_snapshot=_BUNDLED, compatibility_evidence=None)
app = create_app( app = create_app(
settings=settings, settings=Settings(contract_snapshot=None, compatibility_evidence=None),
database_factory=lambda _settings: FakeDatabase(True), database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(), worker_factories=(),
) )
expected = {
6: "openstack-yoga",
7: "openstack-antelope",
8: "openstack-caracal",
9: "openstack-dalmatian",
}
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
for major in (6, 7, 8, 9): for major, catalog_version in expected.items():
response = await client.get("/ui/api/compatibility", params={"major": major}) response = await client.get("/ui/api/compatibility", params={"major": major})
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["catalog_version"] == catalog_version
assert body["levels"]["implemented"]["count"] == body["total_declared"] assert body["levels"]["implemented"]["count"] == body["total_declared"]
+28 -51
View File
@@ -1,95 +1,72 @@
"""Runtime contract hot-swap tests.""" """OpenStack pack remount via /ui/api/contract/apply."""
from pathlib import Path
import pytest import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.config import Settings from app.config import Settings
from app.main import create_app from app.main import create_app
from tests.unit.test_health import FakeDatabase from tests.unit.test_health import FakeDatabase
_BUNDLED_9 = Path( _SERIES = {
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json" 6: "yoga",
) 7: "antelope",
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json") 8: "caracal",
9: "dalmatian",
}
def _app() -> FastAPI: def _app():
settings = Settings(
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
)
return create_app( return create_app(
settings=settings, settings=Settings(contract_snapshot=None),
database_factory=lambda _settings: FakeDatabase(True), database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(), worker_factories=(),
) )
async def test_contract_apply_swaps_version_and_routes() -> None: async def test_contract_apply_swaps_openstack_series() -> None:
app = _app() app = _app()
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
before = await client.get("/api2/json/version")
assert before.status_code == 200
assert before.json()["data"]["version"] == "9.2.3"
assert before.json()["data"]["release"] == "9.2"
versions = await client.get("/ui/api/versions") versions = await client.get("/ui/api/versions")
assert versions.status_code == 200 assert versions.status_code == 200
assert versions.json()["runtime_version"] == "9.2.3" assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9}
applied = await client.post("/ui/api/contract/apply", params={"major": 7}) applied = await client.post("/ui/api/contract/apply", params={"major": 7})
assert applied.status_code == 200 assert applied.status_code == 200
payload = applied.json() payload = applied.json()
assert payload["ok"] is True assert payload["ok"] is True
assert payload["major"] == 7 assert payload["major"] == 7
assert payload["runtime_version"] == "7.4-16" assert payload["series"] == "antelope"
assert payload["path_count"] > 0 assert payload["runtime_version"] == "openstack-antelope"
assert payload["method_count"] > 0 assert (payload.get("method_count") or payload.get("operation_count") or 0) > 0
after = await client.get("/api2/json/version")
assert after.status_code == 200
assert after.json()["data"]["version"] == "7.4-16"
assert after.json()["data"]["release"] == "7.4"
versions_after = await client.get("/ui/api/versions") versions_after = await client.get("/ui/api/versions")
assert versions_after.json()["runtime_version"] == "7.4-16" assert versions_after.json()["runtime_version"] == "openstack-antelope"
# Still routed (handler or 501), not a missing route / 404.
nodes = await client.get("/api2/json/nodes")
assert nodes.status_code in {200, 401, 501}
restored = await client.post("/ui/api/contract/apply", params={"major": 9}) restored = await client.post("/ui/api/contract/apply", params={"major": 9})
assert restored.status_code == 200 assert restored.status_code == 200
assert restored.json()["runtime_version"] == "9.2.3" assert restored.json()["runtime_version"] == "openstack-dalmatian"
assert (await client.get("/api2/json/version")).json()["data"]["version"] == "9.2.3" assert (await client.get("/ui/api/versions")).json()["runtime_version"] == (
"openstack-dalmatian"
)
@pytest.mark.parametrize("major,version", [(6, "6.4-15"), (7, "7.4-16"), (8, "8.4.5")]) @pytest.mark.parametrize("major,series", list(_SERIES.items()))
async def test_contract_apply_loads_per_major_verified_evidence(major: int, version: str) -> None: async def test_contract_apply_sets_runtime_per_series(major: int, series: str) -> None:
app = _app() app = _app()
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
applied = await client.post("/ui/api/contract/apply", params={"major": major}) applied = await client.post("/ui/api/contract/apply", params={"major": major})
assert applied.status_code == 200 assert applied.status_code == 200
assert applied.json()["runtime_version"] == version body = applied.json()
report = await client.get("/admin/compatibility") assert body["runtime_version"] == f"openstack-{series}"
body = report.json() assert body["series"] == series
assert body["source_version"] == version
assert body["levels"]["verified"]["count"] == body["total_declared"]
assert body["levels"]["verified"]["count"] > 0
async def test_contract_apply_requires_bootstrapped_contract() -> None: async def test_contract_apply_works_without_proxmox_snapshot() -> None:
app = create_app( app = _app()
settings=Settings(contract_snapshot=None),
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/ui/api/contract/apply", params={"major": 7}) response = await client.post("/ui/api/contract/apply", params={"major": 7})
assert response.status_code == 503 assert response.status_code == 200
assert response.json()["runtime_version"] == "openstack-antelope"
+1 -1
View File
@@ -27,7 +27,7 @@ def test_console_html_is_read_from_disk() -> None:
): ):
assert f'id="{required_id}"' in html, required_id assert f'id="{required_id}"' in html, required_id
assert "Apply as runtime" in html assert "Apply as runtime" in html
assert "OPENSTACK_SERIES" in html assert "yoga" in html and "dalmatian" in html
assert 'id="help-drawer"' in html assert 'id="help-drawer"' in html
assert 'id="help-badge"' in html assert 'id="help-badge"' in html
assert 'id="data-badge"' in html assert 'id="data-badge"' in html
+25 -53
View File
@@ -1,23 +1,16 @@
"""Web console route tests.""" """Web console route tests."""
from pathlib import Path
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app from app.main import create_app
from tests.unit.test_health import FakeDatabase from tests.unit.test_health import FakeDatabase
_BUNDLED = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
async def test_root_console_is_served() -> None: async def test_root_console_is_served() -> None:
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=()) app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/") response = await client.get("/console")
assert response.status_code == 200 assert response.status_code == 200
assert "OpenStack API Emulator" in response.text assert "OpenStack API Emulator" in response.text
assert "openstack" in response.text assert "openstack" in response.text
@@ -35,60 +28,36 @@ async def test_root_console_is_served() -> None:
assert "Request body" in response.text assert "Request body" in response.text
async def test_ui_method_nodes_is_implemented() -> None: async def test_ui_method_server_list_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED) app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get( method = await client.get(
"/ui/api/method", "/ui/api/method",
params={"major": 7, "path": "/nodes", "verb": "GET"}, params={"major": 9, "path": "/v2.1/servers", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["implemented"] is True
async def test_ui_method_read_group_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/access/groups/{groupid}", "verb": "GET"},
) )
assert method.status_code == 200 assert method.status_code == 200
payload = method.json() payload = method.json()
assert payload["name"] == "read_group"
assert payload["implemented"] is True assert payload["implemented"] is True
assert payload["name"] == "server_list"
assert payload["service"] == "nova"
async def test_ui_catalog_read_group_is_implemented() -> None: async def test_ui_catalog_server_list_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED) app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
catalog = await client.get("/ui/api/catalog", params={"major": 9}) catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200 assert catalog.status_code == 200
body = catalog.json()
assert body["source_version"] == "openstack-dalmatian"
methods = { methods = {
(path["path"], method["name"]): method["implemented"] (path["path"], method["name"]): method["implemented"]
for category in catalog.json()["categories"] for category in body["categories"]
for path in category["paths"] for path in category["paths"]
for method in path["methods"] for method in path["methods"]
} }
assert methods[("/access/groups/{groupid}", "read_group")] is True assert methods[("/v2.1/servers", "server_list")] is True
async def test_demo_api_requires_database() -> None: async def test_demo_api_requires_database() -> None:
@@ -102,23 +71,26 @@ async def test_demo_api_requires_database() -> None:
async def test_ui_versions_and_catalog_endpoints() -> None: async def test_ui_versions_and_catalog_endpoints() -> None:
settings = Settings(contract_snapshot=_BUNDLED) app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app): async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
versions = await client.get("/ui/api/versions") versions = await client.get("/ui/api/versions")
assert versions.status_code == 200 assert versions.status_code == 200
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9} majors = versions.json()["majors"]
assert {item["major"] for item in majors} == {6, 7, 8, 9}
assert {item["latest_version"] for item in majors} == {
"yoga",
"antelope",
"caracal",
"dalmatian",
}
catalog = await client.get("/ui/api/catalog", params={"major": 9}) catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200 assert catalog.status_code == 200
assert catalog.json()["source_version"] == "9.2.3" assert catalog.json()["source_version"] == "openstack-dalmatian"
method = await client.get( method = await client.get(
"/ui/api/method", "/ui/api/method",
params={"major": 9, "path": "/version", "verb": "GET"}, params={"major": 9, "path": "/v2.1/servers", "verb": "GET"},
) )
assert method.status_code == 200 assert method.status_code == 200
assert method.json()["path"] == "/version" assert method.json()["path"] == "/v2.1/servers"
assert method.json()["name"] == "server_list"
@@ -105,7 +105,9 @@ def main() -> int:
print(f" {name}: {count}") print(f" {name}: {count}")
if args.series == "all": if args.series == "all":
# Coverage check requires the project venv (Python 3.13 dataclasses). # Coverage check requires the project venv (Python 3.13 dataclasses).
print("Run coverage with: python tools/os_api_inventory/generate_request_bodies.py --coverage") print(
"Run coverage with: python tools/os_api_inventory/generate_request_bodies.py --coverage"
)
return 0 return 0
@@ -28,9 +28,7 @@ SERVICE_MAP = {
"placement": "placement", "placement": "placement",
} }
API_CONTENTS = ( API_CONTENTS = "https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main"
"https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main"
)
def _load_yaml(text: str) -> dict[str, Any]: def _load_yaml(text: str) -> dict[str, Any]:
+120 -31
View File
@@ -67,6 +67,7 @@ def _envelope(key: str, inner: Str, *, required_inner: bool = True) -> Str:
# --- Resource property libraries (api-ref style) --- # --- Resource property libraries (api-ref style) ---
def _name_desc() -> dict[str, Str]: def _name_desc() -> dict[str, Str]:
return { return {
"name": _s("Human-readable name", example="example"), "name": _s("Human-readable name", example="example"),
@@ -112,7 +113,9 @@ def props_network() -> Str:
"admin_state_up": _b("Administrative state", default=True), "admin_state_up": _b("Administrative state", default=True),
"shared": _b("Shared across projects", default=False), "shared": _b("Shared across projects", default=False),
"external": _b("External network", default=False), "external": _b("External network", default=False),
"provider:network_type": _s("Provider network type", enum=["local", "flat", "vlan", "vxlan", "gre"]), "provider:network_type": _s(
"Provider network type", enum=["local", "flat", "vlan", "vxlan", "gre"]
),
"provider:physical_network": _s("Physical network label"), "provider:physical_network": _s("Physical network label"),
"provider:segmentation_id": _i("Segmentation id"), "provider:segmentation_id": _i("Segmentation id"),
"mtu": _i("MTU", minimum=68, example=1500), "mtu": _i("MTU", minimum=68, example=1500),
@@ -268,7 +271,9 @@ def props_image() -> Str:
enum=["raw", "qcow2", "vmdk", "vdi", "iso", "aki", "ari", "ami"], enum=["raw", "qcow2", "vmdk", "vdi", "iso", "aki", "ari", "ami"],
example="qcow2", example="qcow2",
), ),
"visibility": _s("Visibility", enum=["public", "private", "shared", "community"], example="private"), "visibility": _s(
"Visibility", enum=["public", "private", "shared", "community"], example="private"
),
"protected": _b("Protected"), "protected": _b("Protected"),
"tags": _a(_s()), "tags": _a(_s()),
"min_disk": _i("Min disk GiB", minimum=0, example=0), "min_disk": _i("Min disk GiB", minimum=0, example=0),
@@ -359,7 +364,11 @@ def props_alarm() -> Str:
return _o( return _o(
{ {
**_name_desc(), **_name_desc(),
"type": _s("Alarm type", enum=["threshold", "event", "gnocchi_resources_threshold"], example="threshold"), "type": _s(
"Alarm type",
enum=["threshold", "event", "gnocchi_resources_threshold"],
example="threshold",
),
"enabled": _b("Enabled", default=True), "enabled": _b("Enabled", default=True),
"alarm_actions": _a(_s("Webhook URL", fmt="uri")), "alarm_actions": _a(_s("Webhook URL", fmt="uri")),
"ok_actions": _a(_s(fmt="uri")), "ok_actions": _a(_s(fmt="uri")),
@@ -369,7 +378,9 @@ def props_alarm() -> Str:
{ {
"meter_name": _s(example="cpu_util"), "meter_name": _s(example="cpu_util"),
"threshold": _i(example=80), "threshold": _i(example=80),
"comparison_operator": _s(enum=["gt", "lt", "ge", "le", "eq", "ne"], example="gt"), "comparison_operator": _s(
enum=["gt", "lt", "ge", "le", "eq", "ne"], example="gt"
),
"evaluation_periods": _i(example=1), "evaluation_periods": _i(example=1),
"period": _i(example=60), "period": _i(example=60),
"statistic": _s(enum=["avg", "max", "min", "sum", "count"], example="avg"), "statistic": _s(enum=["avg", "max", "min", "sum", "count"], example="avg"),
@@ -400,7 +411,9 @@ def props_recordset() -> Str:
return _o( return _o(
{ {
"name": _s("Recordset name", example="www.example.com."), "name": _s("Recordset name", example="www.example.com."),
"type": _s("RR type", enum=["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "PTR"], example="A"), "type": _s(
"RR type", enum=["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "PTR"], example="A"
),
"records": _a(_s("Record data"), "Records"), "records": _a(_s("Record data"), "Records"),
"ttl": _i("TTL", example=3600), "ttl": _i("TTL", example=3600),
"description": _s(), "description": _s(),
@@ -432,7 +445,9 @@ def props_listener() -> Str:
{ {
**_name_desc(), **_name_desc(),
"loadbalancer_id": _s(fmt="uuid"), "loadbalancer_id": _s(fmt="uuid"),
"protocol": _s(enum=["HTTP", "HTTPS", "TCP", "UDP", "TERMINATED_HTTPS"], example="HTTP"), "protocol": _s(
enum=["HTTP", "HTTPS", "TCP", "UDP", "TERMINATED_HTTPS"], example="HTTP"
),
"protocol_port": _i(example=80), "protocol_port": _i(example=80),
"connection_limit": _i(example=-1), "connection_limit": _i(example=-1),
"admin_state_up": _b(default=True), "admin_state_up": _b(default=True),
@@ -584,7 +599,9 @@ def props_node() -> Str: # ironic
"ipmi_password": _s(), "ipmi_password": _s(),
} }
), ),
"properties": _o({"cpus": _i(example=4), "memory_mb": _i(example=8192), "local_gb": _i(example=100)}), "properties": _o(
{"cpus": _i(example=4), "memory_mb": _i(example=8192), "local_gb": _i(example=100)}
),
"resource_class": _s(example="baremetal"), "resource_class": _s(example="baremetal"),
"conductor_group": _s(), "conductor_group": _s(),
"network_interface": _s(example="flat"), "network_interface": _s(example="flat"),
@@ -661,7 +678,9 @@ def props_segment() -> Str: # masakari
return _o( return _o(
{ {
**_name_desc(), **_name_desc(),
"recovery_method": _s(enum=["auto", "reserved_host", "auto_priority", "rh_priority"], example="auto"), "recovery_method": _s(
enum=["auto", "reserved_host", "auto_priority", "rh_priority"], example="auto"
),
"service_type": _s(enum=["compute"], example="compute"), "service_type": _s(enum=["compute"], example="compute"),
}, },
required=["name", "recovery_method", "service_type"], required=["name", "recovery_method", "service_type"],
@@ -713,7 +732,9 @@ def props_auth_token() -> Str:
"user": _o( "user": _o(
{ {
"name": _s(example="admin"), "name": _s(example="admin"),
"domain": _o({"name": _s(example="Default")}, required=["name"]), "domain": _o(
{"name": _s(example="Default")}, required=["name"]
),
"password": _s(example="secret"), "password": _s(example="secret"),
"id": _s(fmt="uuid"), "id": _s(fmt="uuid"),
}, },
@@ -866,7 +887,9 @@ def props_server_group() -> Str:
return _o( return _o(
{ {
"name": _s(example="example"), "name": _s(example="example"),
"policies": _a(_s(enum=["affinity", "anti-affinity", "soft-affinity", "soft-anti-affinity"])), "policies": _a(
_s(enum=["affinity", "anti-affinity", "soft-affinity", "soft-anti-affinity"])
),
"policy": _s(enum=["affinity", "anti-affinity", "soft-affinity", "soft-anti-affinity"]), "policy": _s(enum=["affinity", "anti-affinity", "soft-affinity", "soft-anti-affinity"]),
"rules": _o({"max_server_per_host": _i(example=1)}), "rules": _o({"max_server_per_host": _i(example=1)}),
}, },
@@ -1054,7 +1077,10 @@ def props_trunk() -> Str:
def props_qos_policy() -> Str: def props_qos_policy() -> Str:
return _o({**_name_desc(), "shared": _b(default=False), "is_default": _b(default=False)}, required=["name"]) return _o(
{**_name_desc(), "shared": _b(default=False), "is_default": _b(default=False)},
required=["name"],
)
def props_rbac_policy() -> Str: def props_rbac_policy() -> Str:
@@ -1063,7 +1089,9 @@ def props_rbac_policy() -> Str:
"object_type": _s(example="network"), "object_type": _s(example="network"),
"object_id": _s(fmt="uuid"), "object_id": _s(fmt="uuid"),
"target_tenant": _s(fmt="uuid"), "target_tenant": _s(fmt="uuid"),
"action": _s(enum=["access_as_shared", "access_as_external"], example="access_as_shared"), "action": _s(
enum=["access_as_shared", "access_as_external"], example="access_as_shared"
),
}, },
required=["object_type", "object_id", "target_tenant", "action"], required=["object_type", "object_id", "target_tenant", "action"],
) )
@@ -1109,7 +1137,9 @@ def props_healthmonitor() -> Str:
return _o( return _o(
{ {
**_name_desc(), **_name_desc(),
"type": _s(enum=["HTTP", "HTTPS", "PING", "TCP", "TLS-HELLO", "UDP-CONNECT"], example="HTTP"), "type": _s(
enum=["HTTP", "HTTPS", "PING", "TCP", "TLS-HELLO", "UDP-CONNECT"], example="HTTP"
),
"delay": _i(example=5), "delay": _i(example=5),
"timeout": _i(example=3), "timeout": _i(example=3),
"max_retries": _i(example=3), "max_retries": _i(example=3),
@@ -1220,24 +1250,67 @@ _RESOURCE_PROPS: dict[str, Any] = {
"firewall_group": props_firewall_group, "firewall_group": props_firewall_group,
"l7policy": props_l7policy, "l7policy": props_l7policy,
"healthmonitor": props_healthmonitor, "healthmonitor": props_healthmonitor,
"volume_type": lambda: _o({**_name_desc(), "extra_specs": _o({}), "os-volume-type-access:is_public": _b(default=True)}, required=["name"]), "volume_type": lambda: _o(
"consistencygroup": lambda: _o({**_name_desc(), "volume_types": _a(_s())}, required=["name", "volume_types"]), {
"attachment": lambda: _o({"instance_uuid": _s(fmt="uuid"), "volume_uuid": _s(fmt="uuid"), "mode": _s(example="rw")}, required=["instance_uuid", "volume_uuid"]), **_name_desc(),
"share_network": lambda: _o({**_name_desc(), "neutron_net_id": _s(fmt="uuid"), "neutron_subnet_id": _s(fmt="uuid")}, required=["name"]), "extra_specs": _o({}),
"share_type": lambda: _o({**_name_desc(), "extra_specs": _o({"driver_handles_share_servers": _b(default=False)}), "is_public": _b(default=True)}, required=["name", "extra_specs"]), "os-volume-type-access:is_public": _b(default=True),
},
required=["name"],
),
"consistencygroup": lambda: _o(
{**_name_desc(), "volume_types": _a(_s())}, required=["name", "volume_types"]
),
"attachment": lambda: _o(
{"instance_uuid": _s(fmt="uuid"), "volume_uuid": _s(fmt="uuid"), "mode": _s(example="rw")},
required=["instance_uuid", "volume_uuid"],
),
"share_network": lambda: _o(
{**_name_desc(), "neutron_net_id": _s(fmt="uuid"), "neutron_subnet_id": _s(fmt="uuid")},
required=["name"],
),
"share_type": lambda: _o(
{
**_name_desc(),
"extra_specs": _o({"driver_handles_share_servers": _b(default=False)}),
"is_public": _b(default=True),
},
required=["name", "extra_specs"],
),
"template": lambda: props_stack(), "template": lambda: props_stack(),
"vnf": lambda: _o({**_name_desc(), "vnfd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")}, required=["name", "vnfd_id"]), "vnf": lambda: _o(
"vnfd": lambda: _o({**_name_desc(), "attributes": _o({"vnfd": _s()}), "service_types": _a(_o({"service_type": _s(example="vnfd")}))}, required=["name"]), {**_name_desc(), "vnfd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")},
"ns": lambda: _o({**_name_desc(), "nsd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")}, required=["name", "nsd_id"]), required=["name", "vnfd_id"],
),
"vnfd": lambda: _o(
{
**_name_desc(),
"attributes": _o({"vnfd": _s()}),
"service_types": _a(_o({"service_type": _s(example="vnfd")})),
},
required=["name"],
),
"ns": lambda: _o(
{**_name_desc(), "nsd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")},
required=["name", "nsd_id"],
),
"nsd": lambda: _o({**_name_desc(), "attributes": _o({"nsd": _s()})}, required=["name"]), "nsd": lambda: _o({**_name_desc(), "attributes": _o({"nsd": _s()})}, required=["name"]),
"action": lambda: _o({"action": _s(example="os-start"), "name": _s()}, required=["action"]), "action": lambda: _o({"action": _s(example="os-start"), "name": _s()}, required=["action"]),
"rating_module": lambda: _o({**_name_desc(), "enabled": _b(default=True), "priority": _i(example=1)}, required=["name"]), "rating_module": lambda: _o(
{**_name_desc(), "enabled": _b(default=True), "priority": _i(example=1)}, required=["name"]
),
"hashmap_service": lambda: _o({**_name_desc()}, required=["name"]), "hashmap_service": lambda: _o({**_name_desc()}, required=["name"]),
"collector": lambda: _o({**_name_desc(), "url": _s(fmt="uri")}, required=["name", "url"]), "collector": lambda: _o({**_name_desc(), "url": _s(fmt="uri")}, required=["name", "url"]),
"template_definition": lambda: _o({**_name_desc(), "template": _s(), "type": _s()}, required=["name", "template"]), "template_definition": lambda: _o(
"webhook": lambda: _o({**_name_desc(), "url": _s(fmt="uri"), "headers": _o({})}, required=["name", "url"]), {**_name_desc(), "template": _s(), "type": _s()}, required=["name", "template"]
),
"webhook": lambda: _o(
{**_name_desc(), "url": _s(fmt="uri"), "headers": _o({})}, required=["name", "url"]
),
"topology": lambda: _o({**_name_desc(), "graph": _o({})}, required=["name"]), "topology": lambda: _o({**_name_desc(), "graph": _o({})}, required=["name"]),
"template_version": lambda: _o({"id": _s(example="2021-04-16"), "type": _s(example="heat")}, required=["id"]), "template_version": lambda: _o(
{"id": _s(example="2021-04-16"), "type": _s(example="heat")}, required=["id"]
),
} }
@@ -1255,17 +1328,33 @@ def action_schema(action_name: str | None) -> Str:
action = action_name if action_name and action_name != "*" else "os-start" action = action_name if action_name and action_name != "*" else "os-start"
bodies: dict[str, Str] = { bodies: dict[str, Str] = {
"os-getConsoleOutput": _o({action: _o({"length": _i(example=20)})}, required=[action]), "os-getConsoleOutput": _o({action: _o({"length": _i(example=20)})}, required=[action]),
"reboot": _o({action: _o({"type": _s(enum=["SOFT", "HARD"], example="SOFT")}, required=["type"])}, required=[action]), "reboot": _o(
"resize": _o({action: _o({"flavorRef": _s(example="2")}, required=["flavorRef"])}, required=[action]), {action: _o({"type": _s(enum=["SOFT", "HARD"], example="SOFT")}, required=["type"])},
"rebuild": _o( required=[action],
{action: _o({"imageRef": _s(fmt="uuid"), "name": _s(), "adminPass": _s()}, required=["imageRef"])}, ),
"resize": _o(
{action: _o({"flavorRef": _s(example="2")}, required=["flavorRef"])}, required=[action]
),
"rebuild": _o(
{
action: _o(
{"imageRef": _s(fmt="uuid"), "name": _s(), "adminPass": _s()},
required=["imageRef"],
)
},
required=[action],
),
"createImage": _o(
{action: _o({"name": _s(example="example"), "metadata": _o({})}, required=["name"])},
required=[action], required=[action],
), ),
"createImage": _o({action: _o({"name": _s(example="example"), "metadata": _o({})}, required=["name"])}, required=[action]),
} }
if action in bodies: if action in bodies:
return bodies[action] return bodies[action]
return _o({action: {"type": "null", "description": f"Action {action} body (null object)"}}, required=[action]) return _o(
{action: {"type": "null", "description": f"Action {action} body (null object)"}},
required=[action],
)
def schema_for_operation(op: dict[str, Any]) -> Str: def schema_for_operation(op: dict[str, Any]) -> Str: