From 147f04c9a268ff583b68168d3f9ba9fb02dc5a47 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 18 Jul 2026 09:38:45 +0300 Subject: [PATCH] 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. --- README.md | 2 + README.ru.md | 2 + app/openstack/demo_cloud.py | 10 +- app/openstack/request_examples.py | 14 +- app/openstack/routes/cinder.py | 72 +++- app/openstack/routes/nova.py | 307 ++++++++++++++++-- app/openstack/seed_discovery.py | 16 + app/openstack/surface_probe.py | 12 +- docs/README.md | 2 +- docs/hypervisor-lab.md | 15 + docs/operations.md | 36 +- docs/ru/README.md | 2 +- docs/ru/hypervisor-lab.md | 15 + docs/ru/operations.md | 36 +- examples/python/openstack_smoke.py | 91 ++++-- pulumi-tests/README.md | 15 + pulumi-tests/README.ru.md | 15 + pulumi-tests/reports/pulumi-report.html | 2 +- pulumi-tests/reports/series-antelope.json | 42 +-- pulumi-tests/reports/series-caracal.json | 42 +-- pulumi-tests/reports/series-dalmatian.json | 42 +-- pulumi-tests/reports/series-yoga.json | 42 +-- tests/unit/test_compatibility_catalog.py | 39 +-- tests/unit/test_contract_runtime.py | 79 ++--- tests/unit/test_web_assets.py | 2 +- tests/unit/test_web_console.py | 78 ++--- .../generate_request_bodies.py | 4 +- .../os_api_inventory/import_openapi_bodies.py | 4 +- .../os_api_inventory/request_body_catalog.py | 151 +++++++-- 29 files changed, 843 insertions(+), 346 deletions(-) diff --git a/README.md b/README.md index 34e1965..05f49d8 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,8 @@ open pulumi-tests/reports/pulumi-report.html ``` 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`, Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): see diff --git a/README.ru.md b/README.ru.md index 2278d2e..ef39423 100644 --- a/README.ru.md +++ b/README.ru.md @@ -173,6 +173,8 @@ open pulumi-tests/reports/pulumi-report.html ``` Подробности: **[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 (Keystone `:5000`, Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): diff --git a/app/openstack/demo_cloud.py b/app/openstack/demo_cloud.py index fff9fe6..0bdf335 100644 --- a/app/openstack/demo_cloud.py +++ b/app/openstack/demo_cloud.py @@ -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}")), diff --git a/app/openstack/request_examples.py b/app/openstack/request_examples.py index e61fe57..cc12bc8 100644 --- a/app/openstack/request_examples.py +++ b/app/openstack/request_examples.py @@ -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] diff --git a/app/openstack/routes/cinder.py b/app/openstack/routes/cinder.py index 330c9f3..9f9857a 100644 --- a/app/openstack/routes/cinder.py +++ b/app/openstack/routes/cinder.py @@ -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") diff --git a/app/openstack/routes/nova.py b/app/openstack/routes/nova.py index 783212e..4cba281 100644 --- a/app/openstack/routes/nova.py +++ b/app/openstack/routes/nova.py @@ -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") diff --git a/app/openstack/seed_discovery.py b/app/openstack/seed_discovery.py index 22647cc..5bae7b8 100644 --- a/app/openstack/seed_discovery.py +++ b/app/openstack/seed_discovery.py @@ -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", diff --git a/app/openstack/surface_probe.py b/app/openstack/surface_probe.py index b48947e..5b75a6b 100644 --- a/app/openstack/surface_probe.py +++ b/app/openstack/surface_probe.py @@ -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: diff --git a/docs/README.md b/docs/README.md index 8835fa4..ea0bde3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,7 @@ header on each page. Russian mirrors live under [`ru/`](ru/README.md). | [Seed profiles](seed-profiles.md) | `minimal` / `demo` | | [Clients](clients.md) | SDK / CLI | | [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 | | [Security](security.md) | Lab threat model | | [Observability](observability.md) | Health & logs | diff --git a/docs/hypervisor-lab.md b/docs/hypervisor-lab.md index f24ebd9..2ed183b 100644 --- a/docs/hypervisor-lab.md +++ b/docs/hypervisor-lab.md @@ -31,3 +31,18 @@ open reports/pulumi-report.html 5. Destroy stack; emit HTML + JUnit 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`. diff --git a/docs/operations.md b/docs/operations.md index 779fc97..f0f2407 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -112,17 +112,41 @@ make release-build # local tags only See [kubernetes.md](kubernetes.md) for logs, reseed via `kubectl exec`, and uninstall. -## API coverage lab CI (pulumi-tests) +## Testing -Pulumi probes every pack operation across series — see -[hypervisor-lab.md](hypervisor-lab.md). +| Location | What | +|---|---| +| `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 -make test-pulumi-smoke # from repo root -make test-pulumi +make test # offline unit + contract (no Postgres) +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 diff --git a/docs/ru/README.md b/docs/ru/README.md index 2f71689..2a49c15 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -19,7 +19,7 @@ | [Seed-профили](seed-profiles.md) | `minimal` / `demo` | | [Клиенты](clients.md) | SDK / CLI | | [Web UI](web-ui.md) | Консоль и drawers | -| [Эксплуатация](operations.md) | Day-2, релиз, reseed | +| [Эксплуатация](operations.md) | Day-2, релиз, reseed, **тесты** | | [Архитектура](architecture.md) | Компоненты и путь запроса | | [Безопасность](security.md) | Threat model лаборатории | | [Наблюдаемость](observability.md) | Health и логи | diff --git a/docs/ru/hypervisor-lab.md b/docs/ru/hypervisor-lab.md index a976feb..19c9fda 100644 --- a/docs/ru/hypervisor-lab.md +++ b/docs/ru/hypervisor-lab.md @@ -31,3 +31,18 @@ open reports/pulumi-report.html 5. Destroy; HTML + JUnit См. [`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`. diff --git a/docs/ru/operations.md b/docs/ru/operations.md index b7fcd77..cd2a8d1 100644 --- a/docs/ru/operations.md +++ b/docs/ru/operations.md @@ -112,17 +112,41 @@ make release-build # local tags only См. [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 -make test-pulumi-smoke # from repo root -make test-pulumi +make test # офлайн unit + contract (без Postgres) +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` после прогона. ## Обновления diff --git a/examples/python/openstack_smoke.py b/examples/python/openstack_smoke.py index 4d7c03f..2f5dd76 100644 --- a/examples/python/openstack_smoke.py +++ b/examples/python/openstack_smoke.py @@ -10,13 +10,60 @@ import urllib.error import urllib.request 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: 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) CHECKS: list[tuple[str, str, str | None]] = [ ("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: + keystone = _keystone_base() auth = { "auth": { "identity": { @@ -102,7 +118,7 @@ def main() -> int: "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") print("auth", status, "token", bool(token)) if status != 201 or not token: @@ -145,8 +161,11 @@ def main() -> int: print(" FAIL", payload) failed += 1 - # Root discovery per port - for port, name in [(5000, "keystone"), (8774, "nova"), (6385, "ironic"), (8080, "swift")]: + # Root discovery per port (Keystone may be remapped, e.g. host :15000). + 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, "/")) print(f"root.{name}", st, list((payload or {}).keys())[:3]) diff --git a/pulumi-tests/README.md b/pulumi-tests/README.md index 25fac60..7f8422f 100644 --- a/pulumi-tests/README.md +++ b/pulumi-tests/README.md @@ -55,6 +55,21 @@ Pack sizes (ops, without HEAD): yoga ~1060 → antelope ~1108 → caracal ~1196 | `reports/series-.json` | Per-series pulumi + HTTP details | | `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) Nova create may skip a long `BUILD` window; many Nova actions only flip server diff --git a/pulumi-tests/README.ru.md b/pulumi-tests/README.ru.md index 8dbfa17..f9afd3c 100644 --- a/pulumi-tests/README.ru.md +++ b/pulumi-tests/README.ru.md @@ -55,6 +55,21 @@ open reports/pulumi-report.html | `reports/series-.json` | Детали pulumi + HTTP по серии | | `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 (не блокеры) Nova create может пропускать длинное окно `BUILD`; многие Nova actions только diff --git a/pulumi-tests/reports/pulumi-report.html b/pulumi-tests/reports/pulumi-report.html index 19ec9bc..4029703 100644 --- a/pulumi-tests/reports/pulumi-report.html +++ b/pulumi-tests/reports/pulumi-report.html @@ -21,7 +21,7 @@

Pulumi OpenStack API coverage

-

Generated 2026-07-18 03:50:29 UTC · 100% = HTTP contract matrix (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.

+

Generated 2026-07-18 06:30:32 UTC · 100% = HTTP contract matrix (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.

diff --git a/pulumi-tests/reports/series-antelope.json b/pulumi-tests/reports/series-antelope.json index 4dc7858..a1fa0c7 100644 --- a/pulumi-tests/reports/series-antelope.json +++ b/pulumi-tests/reports/series-antelope.json @@ -2,35 +2,35 @@ "series": "antelope", "pulumi": { "series": "antelope", - "elapsed_s": 15.55, + "elapsed_s": 14.74, "error": null, "outputs": { "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", - "created_project_id": "636996ce-af86-4a46-803a-d00392971169", - "created_user_id": "efe09170-ca36-4285-b3a4-025a3002a480", + "created_project_id": "1c910aae-e27d-4aac-81c3-22a830c0ba66", + "created_user_id": "0ef2bed4-d3de-4e3b-bd81-63170d4500bd", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "flavor_id": "1", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_name": "cirros", - "interface_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689/ade9bfa2-3134-45dc-ab23-d622e4e68e5c", - "keypair_name": "pu-antelope-bf6ba4-kp", - "network_id": "13af915a-7f32-4b03-bba3-cef4e3be9ac9", - "port_id": "ade9bfa2-3134-45dc-ab23-d622e4e68e5c", + "interface_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9/5148e3ed-e330-4c07-bf7f-b3df0c5e58a4", + "keypair_name": "pu-antelope-57dc3f-kp", + "network_id": "73f455da-3298-4d24-ad8f-05818041032a", + "port_id": "5148e3ed-e330-4c07-bf7f-b3df0c5e58a4", "project_name": "demo", - "router_id": "e64d25a6-43f6-40d8-a3fe-b80c6768603a", - "router_iface_id": "ba04886a-aff6-464a-ba16-dd067ce6e1dd", - "secgroup_id": "b45ef742-cd84-45f3-92a6-6379facbc10b", - "secgroup_rule_id": "ed4ab26d-9d9d-4a08-a6db-261ed034e01d", + "router_id": "2b01bee5-74f0-42db-a3b3-7384bfde574f", + "router_iface_id": "52fa67db-1688-4f46-ac02-c6fd51ba7885", + "secgroup_id": "7ede16f0-073c-4265-a727-bfd361888cd6", + "secgroup_rule_id": "7c334d3d-e7d8-40f3-82a5-2f8d24de2e4a", "series": "antelope", - "server_group_id": "fbea9f9d-2dc6-485d-ab96-e143525fb781", - "server_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689", - "server_name": "pu-antelope-bf6ba4-vm", - "subnet_id": "46f11ac5-cfe7-4a70-ab29-6ee9e000f8ea", - "tag": "pu-antelope-bf6ba4", - "volume2_id": "e6d48c67-e79c-4db1-b3a6-8b62a3540a2a", - "volume_attach_id": "94e2bacd-4643-4aa7-9f52-fdf83a65d689/7066859a-cf37-4baf-b969-4b2cea586d34", - "volume_id": "a3e4eedd-dc55-4999-b1a9-714f8dfa2c28" + "server_group_id": "1a03413b-9650-445a-a5be-f1e126cf6b57", + "server_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9", + "server_name": "pu-antelope-57dc3f-vm", + "subnet_id": "7039ff93-83f0-4105-b24c-727eaa6a9951", + "tag": "pu-antelope-57dc3f", + "volume2_id": "d64eff18-fcd8-4102-9a0d-467aab6caf40", + "volume_attach_id": "f52771f7-dfbf-43f5-8224-03aa983a6aa9/07e8ed96-ddc2-43a6-bb20-7480194c7669", + "volume_id": "e427eb9d-87f3-44a6-a80f-fc05730e6f2e" }, "empty_exports": [], "ok": true @@ -13053,11 +13053,11 @@ "method": "HEAD", "path": "/v3/os-quota-sets/{id}", "operation_id": "cinder_quota_show__head", - "status": 404, + "status": 200, "detail": "", "mode": "head", "ok": true, - "succeeded": false + "succeeded": true }, { "service": "cinder", diff --git a/pulumi-tests/reports/series-caracal.json b/pulumi-tests/reports/series-caracal.json index 64ee2a7..69d0a7f 100644 --- a/pulumi-tests/reports/series-caracal.json +++ b/pulumi-tests/reports/series-caracal.json @@ -2,35 +2,35 @@ "series": "caracal", "pulumi": { "series": "caracal", - "elapsed_s": 13.94, + "elapsed_s": 17.38, "error": null, "outputs": { "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", - "created_project_id": "4ccc6157-9838-41ed-b609-6fad16c7485b", - "created_user_id": "eb2db72c-1204-40ed-8b34-e099a42a9ece", + "created_project_id": "31415a09-9cde-45d6-a021-44898133fbe7", + "created_user_id": "66a94333-475c-4f96-98ec-690f556fac62", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "flavor_id": "1", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_name": "cirros", - "interface_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e/3a302d32-d14f-4a89-83a4-377b8400dd64", - "keypair_name": "pu-caracal-913e05-kp", - "network_id": "d87e3ba7-88fd-439e-a700-f8c904c5962c", - "port_id": "3a302d32-d14f-4a89-83a4-377b8400dd64", + "interface_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa/4f61a1dc-d739-4b52-861b-945926f7bc60", + "keypair_name": "pu-caracal-079211-kp", + "network_id": "bc261b59-7e65-4f19-94b2-f8736a3d07a9", + "port_id": "4f61a1dc-d739-4b52-861b-945926f7bc60", "project_name": "demo", - "router_id": "dbd5921f-f7af-491d-8b6c-7a108e4dee02", - "router_iface_id": "6dc250c1-3718-4785-a020-eef0f89438cb", - "secgroup_id": "e84c344b-7f7a-4b30-bf40-6f6a176f288e", - "secgroup_rule_id": "ae2be8f8-1c5f-4ebf-81ae-1c78b79089d5", + "router_id": "f8610281-2117-4175-833f-2d532783c6b7", + "router_iface_id": "2c88a159-45b3-4239-9d17-dd4a7a3e438f", + "secgroup_id": "b2dd227c-7000-4545-82b8-d8ddd7b97e43", + "secgroup_rule_id": "c12cd8fe-531e-4ef0-9401-6a60b86f871a", "series": "caracal", - "server_group_id": "6dc9fd5e-5ecf-4289-bd1a-f2a0937cb8e9", - "server_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e", - "server_name": "pu-caracal-913e05-vm", - "subnet_id": "fe94e98e-febd-4348-8e23-b99a04733066", - "tag": "pu-caracal-913e05", - "volume2_id": "79e1cc1c-d0f0-4a63-ad0e-e8be2dc016c0", - "volume_attach_id": "ed7dacac-0d5c-42af-8dec-c9ddc3a3e66e/ef4b5b3d-6518-4959-922a-c717c54f57a9", - "volume_id": "1b947b41-3442-475c-817a-9be42362a2d5" + "server_group_id": "b738e0cb-eb50-41d4-b466-5d594588c52e", + "server_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa", + "server_name": "pu-caracal-079211-vm", + "subnet_id": "0888b0b9-00da-4d67-a70d-b28503d1361f", + "tag": "pu-caracal-079211", + "volume2_id": "eb595372-9882-4553-bc0c-c266784d1d2f", + "volume_attach_id": "4b0dd2c2-a062-4df7-8fba-e5e91b4392aa/b781c110-61ea-4c64-9697-7b7baa5e1d5c", + "volume_id": "43736cfa-2cb8-400a-ad79-88346c437cc3" }, "empty_exports": [], "ok": true @@ -14021,11 +14021,11 @@ "method": "HEAD", "path": "/v3/os-quota-sets/{id}", "operation_id": "cinder_quota_show__head", - "status": 404, + "status": 200, "detail": "", "mode": "head", "ok": true, - "succeeded": false + "succeeded": true }, { "service": "cinder", diff --git a/pulumi-tests/reports/series-dalmatian.json b/pulumi-tests/reports/series-dalmatian.json index f6de49a..d5e88a6 100644 --- a/pulumi-tests/reports/series-dalmatian.json +++ b/pulumi-tests/reports/series-dalmatian.json @@ -2,35 +2,35 @@ "series": "dalmatian", "pulumi": { "series": "dalmatian", - "elapsed_s": 12.89, + "elapsed_s": 11.82, "error": null, "outputs": { "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", - "created_project_id": "3703536d-efea-4bb1-866d-c76a3742e2b9", - "created_user_id": "97347426-e13c-4c1e-8d48-4b4f12d785ae", + "created_project_id": "723a139f-e15b-4971-a062-4824228ec02b", + "created_user_id": "6c8b9175-3885-4dbf-931e-59aca29579fc", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "flavor_id": "1", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_name": "cirros", - "interface_id": "21bffe97-64d0-4b27-9711-dc91a405426c/ae9a0ea7-8438-410d-be4e-19508d097277", - "keypair_name": "pu-dalmatian-70f98d-kp", - "network_id": "c618bb28-a49e-4276-9839-41cbbd5f50bd", - "port_id": "ae9a0ea7-8438-410d-be4e-19508d097277", + "interface_id": "643a3d90-729e-47c4-91be-2d71e048cc8c/9bdada09-684c-49b2-9bbf-e067153740f7", + "keypair_name": "pu-dalmatian-2caef0-kp", + "network_id": "636d541b-b4f8-434f-a2a5-f8432870ee09", + "port_id": "9bdada09-684c-49b2-9bbf-e067153740f7", "project_name": "demo", - "router_id": "7803044e-f510-4876-bc9f-20a9366b3251", - "router_iface_id": "b45e4712-be52-4621-be82-28ed23f8883c", - "secgroup_id": "329d3bd1-15b8-41ed-aeb4-06a88f5d90dd", - "secgroup_rule_id": "06d66321-c89a-4c2f-9323-d5508c8c7677", + "router_id": "1de5abd6-3948-44e1-b009-547f8a390864", + "router_iface_id": "3b94297d-fd08-4486-95eb-136f217a1be6", + "secgroup_id": "1e30a30c-ddca-467d-a4d5-a4edb479d7f3", + "secgroup_rule_id": "7689db06-1c02-4c33-b252-705984aa15d6", "series": "dalmatian", - "server_group_id": "d4ae5792-335f-43ac-bdf0-2422541bace9", - "server_id": "21bffe97-64d0-4b27-9711-dc91a405426c", - "server_name": "pu-dalmatian-70f98d-vm", - "subnet_id": "f5b4fb63-f8a3-4309-861e-4d13b3863c19", - "tag": "pu-dalmatian-70f98d", - "volume2_id": "3adc3ced-c25c-46bd-8786-c00737cf9ac6", - "volume_attach_id": "21bffe97-64d0-4b27-9711-dc91a405426c/60b5be58-d7d8-4d46-8e04-351c19ee435d", - "volume_id": "22d3de1c-3f24-4bdc-bc79-cf4b1a47bd82" + "server_group_id": "53da92a5-9b9e-481c-a813-2445f3eecf21", + "server_id": "643a3d90-729e-47c4-91be-2d71e048cc8c", + "server_name": "pu-dalmatian-2caef0-vm", + "subnet_id": "92350576-ad17-4fd6-b811-5719d3d18b3c", + "tag": "pu-dalmatian-2caef0", + "volume2_id": "5361c5ea-8607-44ff-b4de-52af293137c1", + "volume_attach_id": "643a3d90-729e-47c4-91be-2d71e048cc8c/af788c02-dff0-4ef4-a736-aa385f2de7cf", + "volume_id": "d0eb45de-d29a-45af-948f-7e459f623225" }, "empty_exports": [], "ok": true @@ -15792,11 +15792,11 @@ "method": "HEAD", "path": "/v3/os-quota-sets/{id}", "operation_id": "cinder_quota_show__head", - "status": 404, + "status": 200, "detail": "", "mode": "head", "ok": true, - "succeeded": false + "succeeded": true }, { "service": "cinder", diff --git a/pulumi-tests/reports/series-yoga.json b/pulumi-tests/reports/series-yoga.json index 3a9b065..c386eef 100644 --- a/pulumi-tests/reports/series-yoga.json +++ b/pulumi-tests/reports/series-yoga.json @@ -2,35 +2,35 @@ "series": "yoga", "pulumi": { "series": "yoga", - "elapsed_s": 21.05, + "elapsed_s": 28.01, "error": null, "outputs": { "auth_project_id": "8924e279-51c7-582e-b6f5-8e41b33e7581", "auth_user_id": "b76edf4a-1452-5436-a411-a099ada1f7cb", - "created_project_id": "a719f057-c4e2-4e22-9959-d236923a8ede", - "created_user_id": "32a0fb8d-4a4a-4dd4-b146-c145ffb19715", + "created_project_id": "7420191b-3936-4857-b7e9-eecc8ec7f551", + "created_user_id": "7d4e7f29-6d27-453c-a915-8f2159d79205", "demo_net_id": "a245268b-88ba-597a-b8db-017810782f98", "flavor_id": "1", "image_id": "c58b99c0-2d7b-5842-b260-b617db2f7803", "image_name": "cirros", - "interface_id": "6997d545-7aed-4b1f-b83f-01746593bd83/65b607b4-13bb-4635-9510-041b2d9db881", - "keypair_name": "pu-yoga-f8ae3f-kp", - "network_id": "5e2a0c44-766b-4154-8a80-0bcee1d3b73e", - "port_id": "65b607b4-13bb-4635-9510-041b2d9db881", + "interface_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703/7664e368-1589-4efa-a7d2-d84cde7642a9", + "keypair_name": "pu-yoga-ec8080-kp", + "network_id": "8c2ac66a-6558-4a29-8daf-5cea581ac4ba", + "port_id": "7664e368-1589-4efa-a7d2-d84cde7642a9", "project_name": "demo", - "router_id": "7a57a2dd-7e47-40af-9966-f199dd114fc0", - "router_iface_id": "2a600c25-898c-4444-92f3-698d9faabc86", - "secgroup_id": "59a4612f-754e-4dd4-ad82-cafc05dd8d1a", - "secgroup_rule_id": "cb4a9464-d9f8-4b96-a7f3-00c0a463586d", + "router_id": "95b514eb-b5d2-4781-a567-8e164f28f156", + "router_iface_id": "c144876c-8b2b-4534-900a-1bec13965bba", + "secgroup_id": "f173a8a9-f399-4484-a98f-729f7b7a1a52", + "secgroup_rule_id": "dd83a9ad-aa94-45c5-bf21-828fb04732b6", "series": "yoga", - "server_group_id": "d2b1cd50-ba00-4b6e-b2dc-dae7d32eac18", - "server_id": "6997d545-7aed-4b1f-b83f-01746593bd83", - "server_name": "pu-yoga-f8ae3f-vm", - "subnet_id": "ecab7de1-6c80-4904-9676-f95eb84c84ca", - "tag": "pu-yoga-f8ae3f", - "volume2_id": "ebe99f6c-9148-4079-b6cd-30cda1337731", - "volume_attach_id": "6997d545-7aed-4b1f-b83f-01746593bd83/95982862-b32d-4f22-b29c-b01d8c7d93ad", - "volume_id": "0cdaed66-20e7-4191-8410-b865caffce81" + "server_group_id": "6a089d83-f4d7-4947-aa56-f8720160ae78", + "server_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703", + "server_name": "pu-yoga-ec8080-vm", + "subnet_id": "295c6526-bd1b-4d17-a274-1e06675f4212", + "tag": "pu-yoga-ec8080", + "volume2_id": "954ccc29-ccb6-40af-b524-9a26287782e8", + "volume_attach_id": "db05ea56-c40a-4ac6-acbd-dc5ac25b5703/d794d477-c4f1-4ac9-8904-92f45375cbd2", + "volume_id": "d5709565-755d-4f74-8270-62e0ecef85f5" }, "empty_exports": [], "ok": true @@ -12525,11 +12525,11 @@ "method": "HEAD", "path": "/v3/os-quota-sets/{id}", "operation_id": "cinder_quota_show__head", - "status": 404, + "status": 200, "detail": "", "mode": "head", "ok": true, - "succeeded": false + "succeeded": true }, { "service": "cinder", diff --git a/tests/unit/test_compatibility_catalog.py b/tests/unit/test_compatibility_catalog.py index 1d0c640..5ae7468 100644 --- a/tests/unit/test_compatibility_catalog.py +++ b/tests/unit/test_compatibility_catalog.py @@ -1,10 +1,8 @@ """Catalog-scoped compatibility payload tests.""" from datetime import UTC, datetime -from pathlib import Path from typing import cast -import pytest from httpx import ASGITransport, AsyncClient 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 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: 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: - if not _PVE7.is_file(): - pytest.skip("PVE 7 bundled contract is unavailable") - settings = Settings(contract_snapshot=_BUNDLED) app = create_app( - settings=settings, + settings=Settings(contract_snapshot=None), database_factory=lambda _settings: FakeDatabase(True), worker_factories=(), ) @@ -99,30 +87,33 @@ async def test_ui_compatibility_endpoint_follows_selected_major() -> None: assert major9.status_code == 200 body7 = major7.json() body9 = major9.json() - pve7_snapshot = Snapshot.model_validate_json(_PVE7.read_bytes()) - assert body7["catalog_version"] == pve7_snapshot.source_version - assert body9["catalog_version"] == "9.2.3" - assert body7["total_declared"] == pve7_snapshot.method_count - bundled = Snapshot.model_validate_json(_BUNDLED.read_bytes()) - assert body9["total_declared"] == bundled.method_count + assert body7["catalog_version"] == "openstack-antelope" + assert body9["catalog_version"] == "openstack-dalmatian" + assert body7["total_declared"] >= 1100 + assert body9["total_declared"] >= 1300 assert body7["major"] == 7 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 body9["levels"]["implemented"]["count"] == body9["total_declared"] -async def test_ui_compatibility_covers_all_bundled_majors() -> None: - settings = Settings(contract_snapshot=_BUNDLED, compatibility_evidence=None) +async def test_ui_compatibility_covers_all_openstack_series() -> None: app = create_app( - settings=settings, + settings=Settings(contract_snapshot=None, compatibility_evidence=None), database_factory=lambda _settings: FakeDatabase(True), worker_factories=(), ) + expected = { + 6: "openstack-yoga", + 7: "openstack-antelope", + 8: "openstack-caracal", + 9: "openstack-dalmatian", + } async with app.router.lifespan_context(app): 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}) assert response.status_code == 200 body = response.json() + assert body["catalog_version"] == catalog_version assert body["levels"]["implemented"]["count"] == body["total_declared"] diff --git a/tests/unit/test_contract_runtime.py b/tests/unit/test_contract_runtime.py index 4da44d4..2a7fb4b 100644 --- a/tests/unit/test_contract_runtime.py +++ b/tests/unit/test_contract_runtime.py @@ -1,95 +1,72 @@ -"""Runtime contract hot-swap tests.""" - -from pathlib import Path +"""OpenStack pack remount via /ui/api/contract/apply.""" import pytest -from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.config import Settings from app.main import create_app from tests.unit.test_health import FakeDatabase -_BUNDLED_9 = Path( - "contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json" -) -_EVIDENCE_9 = Path("evidence/pve-9.2.3.json") +_SERIES = { + 6: "yoga", + 7: "antelope", + 8: "caracal", + 9: "dalmatian", +} -def _app() -> FastAPI: - settings = Settings( - contract_snapshot=_BUNDLED_9, - compatibility_evidence=_EVIDENCE_9, - ) +def _app(): return create_app( - settings=settings, + settings=Settings(contract_snapshot=None), database_factory=lambda _settings: FakeDatabase(True), worker_factories=(), ) -async def test_contract_apply_swaps_version_and_routes() -> None: +async def test_contract_apply_swaps_openstack_series() -> None: app = _app() async with app.router.lifespan_context(app): 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") 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}) assert applied.status_code == 200 payload = applied.json() assert payload["ok"] is True assert payload["major"] == 7 - assert payload["runtime_version"] == "7.4-16" - assert payload["path_count"] > 0 - assert payload["method_count"] > 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" + assert payload["series"] == "antelope" + assert payload["runtime_version"] == "openstack-antelope" + assert (payload.get("method_count") or payload.get("operation_count") or 0) > 0 versions_after = await client.get("/ui/api/versions") - assert versions_after.json()["runtime_version"] == "7.4-16" - - # 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} + assert versions_after.json()["runtime_version"] == "openstack-antelope" restored = await client.post("/ui/api/contract/apply", params={"major": 9}) assert restored.status_code == 200 - assert restored.json()["runtime_version"] == "9.2.3" - assert (await client.get("/api2/json/version")).json()["data"]["version"] == "9.2.3" + assert restored.json()["runtime_version"] == "openstack-dalmatian" + 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")]) -async def test_contract_apply_loads_per_major_verified_evidence(major: int, version: str) -> None: +@pytest.mark.parametrize("major,series", list(_SERIES.items())) +async def test_contract_apply_sets_runtime_per_series(major: int, series: str) -> None: app = _app() async with app.router.lifespan_context(app): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: applied = await client.post("/ui/api/contract/apply", params={"major": major}) assert applied.status_code == 200 - assert applied.json()["runtime_version"] == version - report = await client.get("/admin/compatibility") - body = report.json() - assert body["source_version"] == version - assert body["levels"]["verified"]["count"] == body["total_declared"] - assert body["levels"]["verified"]["count"] > 0 + body = applied.json() + assert body["runtime_version"] == f"openstack-{series}" + assert body["series"] == series -async def test_contract_apply_requires_bootstrapped_contract() -> None: - app = create_app( - settings=Settings(contract_snapshot=None), - database_factory=lambda _settings: FakeDatabase(True), - worker_factories=(), - ) +async def test_contract_apply_works_without_proxmox_snapshot() -> None: + app = _app() async with app.router.lifespan_context(app): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: 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" diff --git a/tests/unit/test_web_assets.py b/tests/unit/test_web_assets.py index 86dc81e..0669663 100644 --- a/tests/unit/test_web_assets.py +++ b/tests/unit/test_web_assets.py @@ -27,7 +27,7 @@ def test_console_html_is_read_from_disk() -> None: ): assert f'id="{required_id}"' in html, required_id 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-badge"' in html assert 'id="data-badge"' in html diff --git a/tests/unit/test_web_console.py b/tests/unit/test_web_console.py index 53c06ba..7d8d8f1 100644 --- a/tests/unit/test_web_console.py +++ b/tests/unit/test_web_console.py @@ -1,23 +1,16 @@ """Web console route tests.""" -from pathlib import Path - from httpx import ASGITransport, AsyncClient -from app.config import Settings from app.main import create_app from tests.unit.test_health import FakeDatabase -_BUNDLED = Path( - "contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json" -) - async def test_root_console_is_served() -> None: app = create_app(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: - response = await client.get("/") + response = await client.get("/console") assert response.status_code == 200 assert "OpenStack API Emulator" 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 -async def test_ui_method_nodes_is_implemented() -> None: - settings = Settings(contract_snapshot=_BUNDLED) - app = create_app( - settings=settings, - database_factory=lambda _settings: FakeDatabase(True), - worker_factories=(), - ) +async def test_ui_method_server_list_is_implemented() -> None: + app = create_app(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": 7, "path": "/nodes", "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"}, + params={"major": 9, "path": "/v2.1/servers", "verb": "GET"}, ) assert method.status_code == 200 payload = method.json() - assert payload["name"] == "read_group" assert payload["implemented"] is True + assert payload["name"] == "server_list" + assert payload["service"] == "nova" -async def test_ui_catalog_read_group_is_implemented() -> None: - settings = Settings(contract_snapshot=_BUNDLED) - app = create_app( - settings=settings, - database_factory=lambda _settings: FakeDatabase(True), - worker_factories=(), - ) +async def test_ui_catalog_server_list_is_implemented() -> None: + app = create_app(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: catalog = await client.get("/ui/api/catalog", params={"major": 9}) assert catalog.status_code == 200 + body = catalog.json() + assert body["source_version"] == "openstack-dalmatian" methods = { (path["path"], method["name"]): method["implemented"] - for category in catalog.json()["categories"] + for category in body["categories"] for path in category["paths"] 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: @@ -102,23 +71,26 @@ async def test_demo_api_requires_database() -> None: async def test_ui_versions_and_catalog_endpoints() -> None: - settings = Settings(contract_snapshot=_BUNDLED) - app = create_app( - settings=settings, - 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 AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: versions = await client.get("/ui/api/versions") 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}) assert catalog.status_code == 200 - assert catalog.json()["source_version"] == "9.2.3" + assert catalog.json()["source_version"] == "openstack-dalmatian" method = await client.get( "/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.json()["path"] == "/version" + assert method.json()["path"] == "/v2.1/servers" + assert method.json()["name"] == "server_list" diff --git a/tools/os_api_inventory/generate_request_bodies.py b/tools/os_api_inventory/generate_request_bodies.py index 2e5e26d..488e2e4 100644 --- a/tools/os_api_inventory/generate_request_bodies.py +++ b/tools/os_api_inventory/generate_request_bodies.py @@ -105,7 +105,9 @@ def main() -> int: print(f" {name}: {count}") if args.series == "all": # 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 diff --git a/tools/os_api_inventory/import_openapi_bodies.py b/tools/os_api_inventory/import_openapi_bodies.py index 6bf0a88..2dd02ab 100644 --- a/tools/os_api_inventory/import_openapi_bodies.py +++ b/tools/os_api_inventory/import_openapi_bodies.py @@ -28,9 +28,7 @@ SERVICE_MAP = { "placement": "placement", } -API_CONTENTS = ( - "https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main" -) +API_CONTENTS = "https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main" def _load_yaml(text: str) -> dict[str, Any]: diff --git a/tools/os_api_inventory/request_body_catalog.py b/tools/os_api_inventory/request_body_catalog.py index a8391f1..eeef02b 100644 --- a/tools/os_api_inventory/request_body_catalog.py +++ b/tools/os_api_inventory/request_body_catalog.py @@ -67,6 +67,7 @@ def _envelope(key: str, inner: Str, *, required_inner: bool = True) -> Str: # --- Resource property libraries (api-ref style) --- + def _name_desc() -> dict[str, Str]: return { "name": _s("Human-readable name", example="example"), @@ -112,7 +113,9 @@ def props_network() -> Str: "admin_state_up": _b("Administrative state", default=True), "shared": _b("Shared across projects", 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:segmentation_id": _i("Segmentation id"), "mtu": _i("MTU", minimum=68, example=1500), @@ -268,7 +271,9 @@ def props_image() -> Str: enum=["raw", "qcow2", "vmdk", "vdi", "iso", "aki", "ari", "ami"], 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"), "tags": _a(_s()), "min_disk": _i("Min disk GiB", minimum=0, example=0), @@ -359,7 +364,11 @@ def props_alarm() -> Str: return _o( { **_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), "alarm_actions": _a(_s("Webhook URL", fmt="uri")), "ok_actions": _a(_s(fmt="uri")), @@ -369,7 +378,9 @@ def props_alarm() -> Str: { "meter_name": _s(example="cpu_util"), "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), "period": _i(example=60), "statistic": _s(enum=["avg", "max", "min", "sum", "count"], example="avg"), @@ -400,7 +411,9 @@ def props_recordset() -> Str: return _o( { "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"), "ttl": _i("TTL", example=3600), "description": _s(), @@ -432,7 +445,9 @@ def props_listener() -> Str: { **_name_desc(), "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), "connection_limit": _i(example=-1), "admin_state_up": _b(default=True), @@ -584,7 +599,9 @@ def props_node() -> Str: # ironic "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"), "conductor_group": _s(), "network_interface": _s(example="flat"), @@ -661,7 +678,9 @@ def props_segment() -> Str: # masakari return _o( { **_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"), }, required=["name", "recovery_method", "service_type"], @@ -713,7 +732,9 @@ def props_auth_token() -> Str: "user": _o( { "name": _s(example="admin"), - "domain": _o({"name": _s(example="Default")}, required=["name"]), + "domain": _o( + {"name": _s(example="Default")}, required=["name"] + ), "password": _s(example="secret"), "id": _s(fmt="uuid"), }, @@ -866,7 +887,9 @@ def props_server_group() -> Str: return _o( { "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"]), "rules": _o({"max_server_per_host": _i(example=1)}), }, @@ -1054,7 +1077,10 @@ def props_trunk() -> 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: @@ -1063,7 +1089,9 @@ def props_rbac_policy() -> Str: "object_type": _s(example="network"), "object_id": _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"], ) @@ -1109,7 +1137,9 @@ def props_healthmonitor() -> Str: return _o( { **_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), "timeout": _i(example=3), "max_retries": _i(example=3), @@ -1220,24 +1250,67 @@ _RESOURCE_PROPS: dict[str, Any] = { "firewall_group": props_firewall_group, "l7policy": props_l7policy, "healthmonitor": props_healthmonitor, - "volume_type": lambda: _o({**_name_desc(), "extra_specs": _o({}), "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"]), + "volume_type": lambda: _o( + { + **_name_desc(), + "extra_specs": _o({}), + "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(), - "vnf": lambda: _o({**_name_desc(), "vnfd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")}, 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"]), + "vnf": lambda: _o( + {**_name_desc(), "vnfd_id": _s(fmt="uuid"), "vim_id": _s(fmt="uuid")}, + 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"]), "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"]), "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"]), - "webhook": lambda: _o({**_name_desc(), "url": _s(fmt="uri"), "headers": _o({})}, required=["name", "url"]), + "template_definition": lambda: _o( + {**_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"]), - "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" bodies: dict[str, Str] = { "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]), - "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"])}, + "reboot": _o( + {action: _o({"type": _s(enum=["SOFT", "HARD"], example="SOFT")}, required=["type"])}, + required=[action], + ), + "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], ), - "createImage": _o({action: _o({"name": _s(example="example"), "metadata": _o({})}, required=["name"])}, required=[action]), } if action in bodies: 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: