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