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)**.
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
+2
View File
@@ -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`, …):
+5 -5
View File
@@ -340,8 +340,7 @@ async def seed_openstack_demo(
for i, az in enumerate(AZS):
hosts = [
f"compute-{(j // len(AZS)) + 1:02d}.{az}"
for j in range(i, hypervisor_count, len(AZS))
f"compute-{(j // len(AZS)) + 1:02d}.{az}" for j in range(i, hypervisor_count, len(AZS))
]
await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
@@ -586,8 +585,7 @@ async def seed_openstack_demo(
# Distribute servers across all lab projects (incl. admin — tokens often use admin)
tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo")
hypervisor_names = [
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}"
for i in range(hypervisor_count)
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}" for i in range(hypervisor_count)
]
server_rows = []
@@ -1494,7 +1492,9 @@ async def seed_openstack_demo(
# Nova server groups (specialized table) — denser in demo project
for i in range(server_group_count):
pname = "demo" if i < max(1, server_group_count // 2) else tenant_cycle[i % len(tenant_cycle)]
pname = (
"demo" if i < max(1, server_group_count // 2) else tenant_cycle[i % len(tenant_cycle)]
)
span = max(1, min(8, max(1, server_count // 4)))
members = [
str(oid(f"server:demo:{(3 + (i % span) * 6) % server_count}")),
+6 -8
View File
@@ -101,9 +101,7 @@ def flatten_schema_fields(
child_type = next((t for t in child_type if t != "null"), child_type[0])
nested_props = child.get("properties")
if (child_type == "object" or nested_props) and isinstance(nested_props, dict):
fields.extend(
flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1)
)
fields.extend(flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1))
elif child_type == "array":
items = child.get("items")
if isinstance(items, dict) and (
@@ -111,9 +109,7 @@ def flatten_schema_fields(
):
# Expand one sample element so nested array object fields appear.
fields.extend(
flatten_schema_fields(
items, prefix=f"{path}.0", max_depth=max_depth - 1
)
flatten_schema_fields(items, prefix=f"{path}.0", max_depth=max_depth - 1)
)
else:
fields.append(
@@ -231,8 +227,10 @@ def unflatten_body(values: dict[str, Any]) -> dict[str, Any]:
return
while len(cur) <= idx:
cur.append([] if want_array else {})
if cur[idx] is None or (want_array and not isinstance(cur[idx], list)) or (
not want_array and not isinstance(cur[idx], dict)
if (
cur[idx] is None
or (want_array and not isinstance(cur[idx], list))
or (not want_array and not isinstance(cur[idx], dict))
):
cur[idx] = [] if want_array else {}
cur = cur[idx]
+71 -1
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
import json
from typing import Annotated, Any
from uuid import uuid4
from uuid import NAMESPACE_URL, uuid4, uuid5
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
@@ -14,6 +15,16 @@ from app.openstack.errors import OpenStackError
router = APIRouter(tags=["Cinder"])
_DEFAULT_QUOTA_SET: dict[str, object] = {
"volumes": 100,
"snapshots": 100,
"gigabytes": 1000,
"backups": 10,
"backup_gigabytes": 1000,
"groups": 10,
"per_volume_gigabytes": -1,
}
def _volume(row: Any) -> dict[str, Any]:
return {
@@ -46,6 +57,65 @@ async def cinder_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dic
)
async def _quota_set_for(
conn: Connection,
*,
tenant_id: str,
project_id: Any,
) -> dict[str, object]:
from app.openstack.db_docs import fetch_doc
row = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service='cinder' AND resource_type='quota_set'
AND (id::text=$1 OR name=$1 OR data->>'id'=$1 OR data->>'tenant_id'=$1)
ORDER BY updated_at DESC LIMIT 1""",
tenant_id,
)
if row is not None:
data = row["data"]
if isinstance(data, str):
data = json.loads(data)
quota = dict((data or {}).get("quota_set") or data or {})
quota.setdefault("id", tenant_id)
return {"quota_set": quota}
defaults = (
await fetch_doc(conn, service="cinder", resource_type="quota_set_defaults", name="default")
or {}
)
quota = dict((defaults.get("quota_set") or _DEFAULT_QUOTA_SET))
quota["id"] = tenant_id
# Stable per-tenant id that does not collide with Nova's project-UUID quota rows.
item_id = uuid5(NAMESPACE_URL, f"cinder:quota_set:{tenant_id}")
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'cinder','quota_set',$2,$3,'ACTIVE',$4::jsonb)
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data, updated_at=now()""",
item_id,
project_id,
tenant_id,
json.dumps({"id": tenant_id, "tenant_id": tenant_id, "quota_set": quota}),
)
return {"quota_set": quota}
@router.get("/v3/os-quota-sets/{tenant_id}")
@router.get("/v3/os-quota-sets/{id}")
@router.get("/v3/{project_id}/os-quota-sets/{tenant_id}")
@router.get("/v3/{project_id}/os-quota-sets/{id}")
async def show_quota_set(
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
tenant_id: str | None = None,
id: str | None = None,
project_id: str | None = None,
) -> dict[str, object]:
_ = project_id
return await _quota_set_for(
conn, tenant_id=tenant_id or id or str(ctx.project_id), project_id=ctx.project_id
)
@router.get("/v3/{project_id}/volumes")
@router.get("/v3/{project_id}/volumes/detail")
@router.get("/v3/volumes")
+280 -27
View File
@@ -929,12 +929,36 @@ async def list_aggregates(
return {"aggregates": [_aggregate_dict(r) for r in rows]}
@router.get("/v2.1/os-aggregates/{aggregate_id}")
async def show_aggregate(
aggregate_id: str,
@router.post("/v2.1/os-aggregates", status_code=201)
async def create_aggregate(
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
payload = (await request.json()).get("aggregate") or {}
name = str(payload.get("name") or f"agg-{uuid4().hex[:8]}")
az = payload.get("availability_zone")
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
hosts = payload.get("hosts") if isinstance(payload.get("hosts"), list) else []
next_id = await conn.fetchval("SELECT COALESCE(MAX(id), 0) + 1 FROM os_aggregates")
row = await conn.fetchrow(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
VALUES($1,$2,$3,$4::jsonb,$5::jsonb)
ON CONFLICT (name) DO UPDATE SET
availability_zone=EXCLUDED.availability_zone,
hosts=EXCLUDED.hosts,
metadata=EXCLUDED.metadata
RETURNING *""",
int(next_id or 1),
name,
None if az is None else str(az),
json.dumps(hosts),
json.dumps(metadata),
)
return {"aggregate": _aggregate_dict(row)}
async def _fetch_aggregate(conn: Connection, aggregate_id: str) -> Any:
row = await conn.fetchrow(
"""SELECT * FROM os_aggregates
WHERE id::text=$1 OR name=$1
@@ -943,7 +967,106 @@ async def show_aggregate(
)
if row is None:
raise OpenStackError("NotFound", f"aggregate {aggregate_id} not found", status_code=404)
return {"aggregate": _aggregate_dict(row)}
return row
async def _show_aggregate_impl(conn: Connection, aggregate_id: str) -> dict[str, object]:
return {"aggregate": _aggregate_dict(await _fetch_aggregate(conn, aggregate_id))}
async def _update_aggregate_impl(
request: Request, conn: Connection, aggregate_id: str
) -> dict[str, object]:
row = await _fetch_aggregate(conn, aggregate_id)
body = await request.json()
payload = body.get("aggregate") if isinstance(body.get("aggregate"), dict) else body
if not isinstance(payload, dict):
payload = {}
name = str(payload.get("name") or row["name"])
az = payload.get("availability_zone", row["availability_zone"])
current = _aggregate_dict(row)
metadata = (
payload.get("metadata")
if isinstance(payload.get("metadata"), dict)
else current["metadata"]
)
hosts = payload.get("hosts") if isinstance(payload.get("hosts"), list) else current["hosts"]
updated = await conn.fetchrow(
"""UPDATE os_aggregates
SET name=$2, availability_zone=$3, hosts=$4::jsonb, metadata=$5::jsonb
WHERE id=$1
RETURNING *""",
row["id"],
name,
None if az is None else str(az),
json.dumps(hosts),
json.dumps(metadata),
)
return {"aggregate": _aggregate_dict(updated)}
async def _delete_aggregate_impl(conn: Connection, aggregate_id: str) -> Response:
row = await _fetch_aggregate(conn, aggregate_id)
await conn.execute("DELETE FROM os_aggregates WHERE id=$1", row["id"])
return Response(status_code=204)
@router.get("/v2.1/os-aggregates/{aggregate_id}")
async def show_aggregate(
aggregate_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _show_aggregate_impl(conn, aggregate_id)
@router.get("/v2.1/os-aggregates/{id}")
async def show_aggregate_by_id(
id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _show_aggregate_impl(conn, id)
@router.put("/v2.1/os-aggregates/{aggregate_id}")
@router.patch("/v2.1/os-aggregates/{aggregate_id}")
async def update_aggregate(
aggregate_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_aggregate_impl(request, conn, aggregate_id)
@router.put("/v2.1/os-aggregates/{id}")
@router.patch("/v2.1/os-aggregates/{id}")
async def update_aggregate_by_id(
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
return await _update_aggregate_impl(request, conn, id)
@router.delete("/v2.1/os-aggregates/{aggregate_id}", status_code=204)
async def delete_aggregate(
aggregate_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
return await _delete_aggregate_impl(conn, aggregate_id)
@router.delete("/v2.1/os-aggregates/{id}", status_code=204)
async def delete_aggregate_by_id(
id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
return await _delete_aggregate_impl(conn, id)
@router.get("/v2.1/os-services")
@@ -1559,6 +1682,33 @@ async def delete_server_tag(
return Response(status_code=204)
def _server_sg_dict(row: Any) -> dict[str, object]:
return {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
}
async def _fetch_server_security_group(
conn: Connection, project_id: UUID, security_group_id: str
) -> Any:
row = await conn.fetchrow(
"""SELECT * FROM os_security_groups
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
LIMIT 1""",
project_id,
security_group_id,
)
if row is None:
raise OpenStackError(
"NotFound",
f"security_group {security_group_id} not found",
status_code=404,
)
return row
@router.get("/v2.1/servers/{server_id}/os-security-groups")
async def server_security_groups(
server_id: str,
@@ -1570,11 +1720,74 @@ 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}")
@@ -1585,26 +1798,66 @@ async def show_server_security_group(
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = server_id
row = await conn.fetchrow(
"""SELECT * FROM os_security_groups
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
LIMIT 1""",
ctx.project_id,
security_group_id,
)
if row is None:
raise OpenStackError(
"NotFound",
f"security_group {security_group_id} not found",
status_code=404,
)
return {
"security_group": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
}
}
return await _show_server_sg_impl(conn, ctx.project_id, security_group_id)
@router.get("/v2.1/servers/{server_id}/os-security-groups/{id}")
async def show_server_security_group_by_id(
server_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = server_id
return await _show_server_sg_impl(conn, ctx.project_id, id)
@router.put("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
@router.patch("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
async def update_server_security_group(
server_id: str,
security_group_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = server_id
return await _update_server_sg_impl(request, conn, ctx.project_id, security_group_id)
@router.put("/v2.1/servers/{server_id}/os-security-groups/{id}")
@router.patch("/v2.1/servers/{server_id}/os-security-groups/{id}")
async def update_server_security_group_by_id(
server_id: str,
id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = server_id
return await _update_server_sg_impl(request, conn, ctx.project_id, id)
@router.delete("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}", status_code=204)
async def delete_server_security_group(
server_id: str,
security_group_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = server_id
return await _delete_server_sg_impl(conn, ctx.project_id, security_group_id)
@router.delete("/v2.1/servers/{server_id}/os-security-groups/{id}", status_code=204)
async def delete_server_security_group_by_id(
server_id: str,
id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> Response:
_ = server_id
return await _delete_server_sg_impl(conn, ctx.project_id, id)
@router.get("/v2.1/servers/{server_id}/topology")
+16
View File
@@ -459,6 +459,22 @@ async def seed_discovery_documents(conn: Connection) -> dict[str, int]:
}
},
),
(
"cinder",
"quota_set_defaults",
"default",
{
"quota_set": {
"volumes": 100,
"snapshots": 100,
"gigabytes": 1000,
"backups": 10,
"backup_gigabytes": 1000,
"groups": 10,
"per_volume_gigabytes": -1,
}
},
),
(
"nova",
"console_auth_token_defaults",
+6 -6
View File
@@ -496,7 +496,11 @@ def probe_operation(
base = service_base_url(host, pack.name, port=pack.port)
url = f"{base}{path}"
method = (method_override or op.method).upper()
data = None if method in {"GET", "HEAD", "DELETE"} else _body_for(op, ctx=path_ctx, project_id=project_id)
data = (
None
if method in {"GET", "HEAD", "DELETE"}
else _body_for(op, ctx=path_ctx, project_id=project_id)
)
mv = microversion_headers(pack, op)
status, payload = http_request(
method,
@@ -886,11 +890,7 @@ def probe_series_lifecycle(
local["server_id"] = rid
# Swift: empty the container before DELETE so the API returns 204
# (409 Conflict for a non-empty container is correct OpenStack behaviour).
if (
op.method == "DELETE"
and pack.name == "swift"
and op.resource_type == "container"
):
if op.method == "DELETE" and pack.name == "swift" and op.resource_type == "container":
acct = local.get("account") or project_id
cname = local.get("container") or local.get("id")
if acct and cname:
+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` |
| [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 |
+15
View File
@@ -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`.
+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
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
+1 -1
View File
@@ -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 и логи |
+15
View File
@@ -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`.
+30 -6
View File
@@ -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` после прогона.
## Обновления
+55 -36
View File
@@ -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])
+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/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
+15
View File
@@ -55,6 +55,21 @@ open reports/pulumi-report.html
| `reports/series-<name>.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 только
+1 -1
View File
@@ -21,7 +21,7 @@
<body>
<header>
<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>
<main>
<div class="summary">
+21 -21
View File
@@ -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",
+21 -21
View File
@@ -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",
+21 -21
View File
@@ -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",
+21 -21
View File
@@ -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",
+15 -24
View File
@@ -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"]
+28 -51
View File
@@ -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"
+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 "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
+25 -53
View File
@@ -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"
@@ -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
@@ -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]:
+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) ---
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: