Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Live gateway probe: every pack operation must be handled (no 5xx / 501)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from app.openstack.surface_probe import format_report, probe_series
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _pick_host() -> str:
|
||||
candidates = [
|
||||
os.environ.get("OS_PROBE_HOST"),
|
||||
os.environ.get("OS_HOST"),
|
||||
"http://127.0.0.1:5000",
|
||||
"http://api-gateway:5000",
|
||||
"http://localhost:5000",
|
||||
]
|
||||
for host in candidates:
|
||||
if not host:
|
||||
continue
|
||||
try:
|
||||
with urllib.request.urlopen(f"{host.rstrip('/')}/health/live", timeout=3) as res:
|
||||
if res.status == 200:
|
||||
return host.rstrip("/")
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
HOST = _pick_host()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_gateway():
|
||||
if not HOST:
|
||||
pytest.skip("OpenStack gateway unreachable")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
|
||||
def test_all_get_collections_live(series: str) -> None:
|
||||
report = probe_series(series, host=HOST, collections_only=True)
|
||||
assert report.results, series
|
||||
if report.failures:
|
||||
pytest.fail(format_report(report))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
|
||||
def test_all_operations_live(series: str) -> None:
|
||||
report = probe_series(series, host=HOST)
|
||||
assert len(report.results) >= 900
|
||||
if report.failures:
|
||||
pytest.fail(format_report(report))
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Live gateway tests: real DB-backed GET/PUT/POST/DELETE after demo seed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _pick_host() -> str:
|
||||
candidates = [
|
||||
os.environ.get("OS_PROBE_HOST"),
|
||||
os.environ.get("OS_HOST"),
|
||||
"http://127.0.0.1:5000",
|
||||
"http://api-gateway:5000",
|
||||
"http://localhost:5000",
|
||||
]
|
||||
for host in candidates:
|
||||
if not host:
|
||||
continue
|
||||
try:
|
||||
with urllib.request.urlopen(f"{host.rstrip('/')}/health/live", timeout=3) as res:
|
||||
if res.status == 200:
|
||||
return host.rstrip("/")
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
HOST = _pick_host()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_gateway():
|
||||
if not HOST:
|
||||
pytest.skip("OpenStack gateway unreachable")
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
token: str | None = None,
|
||||
service: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
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 service:
|
||||
headers["X-OpenStack-Route-Service"] = service
|
||||
req = urllib.request.Request(f"{HOST}{path}", data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as res:
|
||||
raw = res.read().decode()
|
||||
return res.status, 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, parsed
|
||||
|
||||
|
||||
def _auth() -> tuple[str, str]:
|
||||
status, body = _request(
|
||||
"POST",
|
||||
"/v3/auth/tokens",
|
||||
service="keystone",
|
||||
data={
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": "admin",
|
||||
"domain": {"name": "Default"},
|
||||
"password": "secret",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
# urllib may not expose subject token via our helper — re-auth with headers
|
||||
payload = json.dumps(
|
||||
{
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": "admin",
|
||||
"domain": {"name": "Default"},
|
||||
"password": "secret",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{HOST}/v3/auth/tokens",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-OpenStack-Route-Service": "keystone",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as res:
|
||||
token = res.headers.get("X-Subject-Token") or res.headers.get("x-subject-token")
|
||||
parsed = json.loads(res.read().decode() or "{}")
|
||||
assert token, (status, body)
|
||||
project_id = str(((parsed.get("token") or {}).get("project") or {}).get("id") or "")
|
||||
assert project_id
|
||||
return token, project_id
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_ctx():
|
||||
# Ensure demo inventory is present for density assertions.
|
||||
from app.openstack.surface_probe import http_request
|
||||
|
||||
http_request("POST", f"{HOST}/ui/api/demo/load", data={})
|
||||
return _auth()
|
||||
|
||||
|
||||
def test_demo_collections_have_real_density(auth_ctx: tuple[str, str]) -> None:
|
||||
token, _pid = auth_ctx
|
||||
expectations = [
|
||||
("nova", "/v2.1/servers", "servers", 50),
|
||||
("nova", "/v2.1/flavors", "flavors", 4),
|
||||
("nova", "/v2.1/os-keypairs", "keypairs", 3),
|
||||
("nova", "/v2.1/os-server-groups", "server_groups", 4),
|
||||
("neutron", "/v2.0/networks", "networks", 3),
|
||||
("neutron", "/v2.0/subnets", "subnets", 3),
|
||||
("neutron", "/v2.0/routers", "routers", 2),
|
||||
("neutron", "/v2.0/security-groups", "security_groups", 3),
|
||||
("neutron", "/v2.0/ports", "ports", 50),
|
||||
("neutron", "/v2.0/quotas", "quotas", 1),
|
||||
("glance", "/v2/images", "images", 2),
|
||||
(
|
||||
"cinder",
|
||||
"/v3/volumes/detail",
|
||||
"volumes",
|
||||
20,
|
||||
), # project-scoped list also on /v3/{pid}/...
|
||||
("placement", "/resource_providers", "resource_providers", 4),
|
||||
("octavia", "/v2/lbaas/providers", "providers", 3),
|
||||
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", 1),
|
||||
("barbican", "/v1/secrets", "secrets", 4),
|
||||
("heat", f"/v1/{_pid}/stacks", "stacks", 1),
|
||||
("heat", f"/v1/{_pid}/software_configs", "software_configs", 4),
|
||||
("heat", f"/v1/{_pid}/software_deployments", "software_deployments", 4),
|
||||
]
|
||||
for service, path, key, minimum in expectations:
|
||||
status, body = _request("GET", path, token=token, service=service)
|
||||
assert status == 200, (service, path, status, body)
|
||||
assert isinstance(body, dict), (service, path, body)
|
||||
items = body.get(key)
|
||||
assert isinstance(items, list), (service, path, key, body)
|
||||
assert len(items) >= minimum, f"{service} {path} {key}: got {len(items)} < {minimum}"
|
||||
|
||||
|
||||
def test_network_crud_persists_in_db(auth_ctx: tuple[str, str]) -> None:
|
||||
token, _pid = auth_ctx
|
||||
name = "real-db-net"
|
||||
status, created = _request(
|
||||
"POST",
|
||||
"/v2.0/networks",
|
||||
token=token,
|
||||
service="neutron",
|
||||
data={"network": {"name": name, "admin_state_up": True}},
|
||||
)
|
||||
assert status in {200, 201}, created
|
||||
net_id = (created or {}).get("network", {}).get("id")
|
||||
assert net_id
|
||||
|
||||
status, shown = _request("GET", f"/v2.0/networks/{net_id}", token=token, service="neutron")
|
||||
assert status == 200
|
||||
assert shown["network"]["name"] == name
|
||||
|
||||
status, updated = _request(
|
||||
"PUT",
|
||||
f"/v2.0/networks/{net_id}",
|
||||
token=token,
|
||||
service="neutron",
|
||||
data={"network": {"name": f"{name}-upd"}},
|
||||
)
|
||||
assert status == 200
|
||||
assert updated["network"]["name"] == f"{name}-upd"
|
||||
|
||||
status, listed = _request("GET", "/v2.0/networks", token=token, service="neutron")
|
||||
assert status == 200
|
||||
names = {n.get("name") for n in listed.get("networks") or []}
|
||||
assert f"{name}-upd" in names
|
||||
|
||||
status, _ = _request("DELETE", f"/v2.0/networks/{net_id}", token=token, service="neutron")
|
||||
assert status in {200, 202, 204}
|
||||
status, _ = _request("GET", f"/v2.0/networks/{net_id}", token=token, service="neutron")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_server_metadata_persists_roundtrip(auth_ctx: tuple[str, str]) -> None:
|
||||
token, _pid = auth_ctx
|
||||
status, servers = _request("GET", "/v2.1/servers", token=token, service="nova")
|
||||
assert status == 200
|
||||
server_id = (servers.get("servers") or [{}])[0].get("id")
|
||||
assert server_id
|
||||
|
||||
status, _ = _request(
|
||||
"POST",
|
||||
f"/v2.1/servers/{server_id}/metadata",
|
||||
token=token,
|
||||
service="nova",
|
||||
data={"metadata": {"audit": "yes", "tier": "web"}},
|
||||
)
|
||||
assert status in {200, 201}
|
||||
|
||||
status, meta = _request(
|
||||
"GET", f"/v2.1/servers/{server_id}/metadata", token=token, service="nova"
|
||||
)
|
||||
assert status == 200
|
||||
assert meta["metadata"].get("audit") == "yes"
|
||||
assert meta["metadata"].get("tier") == "web"
|
||||
|
||||
status, _ = _request(
|
||||
"PUT",
|
||||
f"/v2.1/servers/{server_id}/tags",
|
||||
token=token,
|
||||
service="nova",
|
||||
data={"tags": ["audit", "web", "demo"]},
|
||||
)
|
||||
assert status in {200, 201}
|
||||
status, tags = _request("GET", f"/v2.1/servers/{server_id}/tags", token=token, service="nova")
|
||||
assert status == 200
|
||||
assert set(tags.get("tags") or []) >= {"audit", "web", "demo"}
|
||||
|
||||
|
||||
def test_schema_secret_crud_persists(auth_ctx: tuple[str, str]) -> None:
|
||||
token, _pid = auth_ctx
|
||||
status, created = _request(
|
||||
"POST",
|
||||
"/v1/secrets",
|
||||
token=token,
|
||||
service="barbican",
|
||||
data={"name": "real-db-secret", "secret_type": "passphrase"},
|
||||
)
|
||||
assert status in {200, 201}, created
|
||||
secret_id = None
|
||||
if isinstance(created, dict):
|
||||
secret_id = created.get("id") or (created.get("secret") or {}).get("id")
|
||||
ref = created.get("secret_ref")
|
||||
if not secret_id and isinstance(ref, str):
|
||||
secret_id = ref.rstrip("/").split("/")[-1]
|
||||
assert secret_id
|
||||
|
||||
status, shown = _request("GET", f"/v1/secrets/{secret_id}", token=token, service="barbican")
|
||||
assert status == 200
|
||||
body = shown.get("secret") if isinstance(shown, dict) and "secret" in shown else shown
|
||||
assert isinstance(body, dict)
|
||||
assert body.get("name") == "real-db-secret" or body.get("id") == secret_id
|
||||
|
||||
status, listed = _request("GET", "/v1/secrets", token=token, service="barbican")
|
||||
assert status == 200
|
||||
ids = []
|
||||
for item in listed.get("secrets") or []:
|
||||
if isinstance(item, dict):
|
||||
ids.append(str(item.get("id") or ""))
|
||||
href = item.get("secret_ref") or item.get("href")
|
||||
if isinstance(href, str):
|
||||
ids.append(href.rstrip("/").split("/")[-1])
|
||||
assert secret_id in ids
|
||||
|
||||
status, _ = _request("DELETE", f"/v1/secrets/{secret_id}", token=token, service="barbican")
|
||||
assert status in {200, 202, 204}
|
||||
status, _ = _request("GET", f"/v1/secrets/{secret_id}", token=token, service="barbican")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_nested_demo_resources_populated(auth_ctx: tuple[str, str]) -> None:
|
||||
token, pid = auth_ctx
|
||||
status, servers = _request("GET", "/v2.1/servers", token=token, service="nova")
|
||||
sid = (servers.get("servers") or [{}])[0].get("id")
|
||||
status, routers = _request("GET", "/v2.0/routers", token=token, service="neutron")
|
||||
rid = (routers.get("routers") or [{}])[0].get("id")
|
||||
status, fips = _request("GET", "/v2.0/floatingips", token=token, service="neutron")
|
||||
fid = (fips.get("floatingips") or [{}])[0].get("id")
|
||||
status, images = _request("GET", "/v2/images", token=token, service="glance")
|
||||
iid = (images.get("images") or [{}])[0].get("id")
|
||||
assert all([sid, rid, fid, iid])
|
||||
|
||||
checks = [
|
||||
("nova", f"/v2.1/servers/{sid}/os-volume_attachments", "volumeAttachments", 1),
|
||||
("nova", f"/v2.1/servers/{sid}/os-interface", "interfaceAttachments", 1),
|
||||
("nova", f"/v2.1/servers/{sid}/metadata", "metadata", 1),
|
||||
("nova", f"/v2.1/servers/{sid}/tags", "tags", 1),
|
||||
("neutron", f"/v2.0/routers/{rid}/conntrack_helpers", "conntrack_helpers", 4),
|
||||
("neutron", f"/v2.0/floatingips/{fid}/port_forwardings", "port_forwardings", 4),
|
||||
("glance", f"/v2/images/{iid}/members", "members", 4),
|
||||
("placement", f"/allocations/{sid}", "allocations", 1),
|
||||
("heat", f"/v1/{pid}/software_deployments", "software_deployments", 4),
|
||||
]
|
||||
for service, path, key, minimum in checks:
|
||||
status, body = _request("GET", path, token=token, service=service)
|
||||
assert status == 200, (path, status, body)
|
||||
val = body.get(key)
|
||||
if isinstance(val, dict):
|
||||
assert len(val) >= minimum, (path, key, val)
|
||||
else:
|
||||
assert isinstance(val, list) and len(val) >= minimum, (path, key, val)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Conformance: every pack operation has method+path and core services are complete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.openstack.contract_loader import contracts_root, load_series_pack
|
||||
|
||||
CORE = ("keystone", "nova", "neutron", "glance", "cinder", "placement")
|
||||
EXTRA = ("heat", "swift", "ironic", "octavia")
|
||||
REMAINING = (
|
||||
"barbican",
|
||||
"manila",
|
||||
"designate",
|
||||
"magnum",
|
||||
"zun",
|
||||
"trove",
|
||||
"mistral",
|
||||
"aodh",
|
||||
"cloudkitty",
|
||||
"freezer",
|
||||
"blazar",
|
||||
"vitrage",
|
||||
"masakari",
|
||||
"tacker",
|
||||
"adjutant",
|
||||
"heat-cfn",
|
||||
"watcher",
|
||||
"zaqar",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
|
||||
def test_pack_operations_are_well_formed(series: str) -> None:
|
||||
packs = load_series_pack(series)
|
||||
for name, pack in packs.items():
|
||||
assert pack.port > 0
|
||||
assert pack.operations, name
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for op in pack.operations:
|
||||
assert op.method in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
|
||||
assert op.path.startswith("/"), op.path
|
||||
assert op.operation_id
|
||||
key = (op.method, op.path)
|
||||
# duplicate method+path only allowed if both are actions collapsing
|
||||
if key in seen:
|
||||
assert op.kind == "action"
|
||||
seen.add(key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", CORE)
|
||||
def test_core_services_have_nested_or_actions(service: str) -> None:
|
||||
pack = load_series_pack("dalmatian")[service]
|
||||
paths = {op.path for op in pack.operations}
|
||||
assert any("{" in p for p in paths) or service == "keystone"
|
||||
if service == "nova":
|
||||
assert "/v2.1/servers/{id}/action" in paths
|
||||
if service == "neutron":
|
||||
assert "/v2.0/routers/{id}/add_router_interface" in paths or any(
|
||||
"add_router_interface" in p for p in paths
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service", EXTRA + REMAINING)
|
||||
def test_extended_services_present(service: str) -> None:
|
||||
packs = load_series_pack("dalmatian")
|
||||
assert service in packs
|
||||
assert packs[service].operation_count() >= 3
|
||||
|
||||
|
||||
def test_coverage_doc_matches_manifest() -> None:
|
||||
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
|
||||
doc = Path(__file__).resolve().parents[3] / "docs" / "api_coverage.md"
|
||||
if not doc.is_file():
|
||||
pytest.skip("docs/api_coverage.md not generated yet")
|
||||
text = doc.read_text()
|
||||
assert str(man["operation_count"]) in text
|
||||
assert "nova" in text
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Compare simulator packs with published OpenStack 2024.2 API surface expectations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.openstack.contract_loader import contracts_root, load_series_pack
|
||||
|
||||
# Services listed on https://docs.openstack.org/2024.2/api/index.html
|
||||
DALMATIAN_API_INDEX_SERVICES = {
|
||||
"ironic",
|
||||
"cinder",
|
||||
"nova",
|
||||
"magnum",
|
||||
"zun",
|
||||
"trove",
|
||||
"designate",
|
||||
"keystone",
|
||||
"glance",
|
||||
"watcher",
|
||||
"masakari",
|
||||
"barbican",
|
||||
"octavia",
|
||||
"zaqar",
|
||||
"neutron",
|
||||
"tacker",
|
||||
"swift",
|
||||
"heat",
|
||||
"placement",
|
||||
"cloudkitty",
|
||||
"blazar",
|
||||
"manila",
|
||||
}
|
||||
|
||||
|
||||
def test_dalmatian_covers_official_2024_2_api_index_services() -> None:
|
||||
packs = load_series_pack("dalmatian")
|
||||
missing = sorted(DALMATIAN_API_INDEX_SERVICES - set(packs))
|
||||
assert missing == [], f"missing official 2024.2 API index services: {missing}"
|
||||
|
||||
|
||||
def test_dalmatian_surface_beats_prior_baseline() -> None:
|
||||
"""Baseline before watcher/zaqar + neutron/nova expansion was 1144 / 26."""
|
||||
|
||||
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
|
||||
assert man["service_count"] >= 28
|
||||
assert man["operation_count"] >= 1300
|
||||
|
||||
|
||||
def test_neutron_and_nova_closer_to_api_ref_counts() -> None:
|
||||
"""Public Neutron API-ref lists ~315 unique method+path pairs; Nova ~200+.
|
||||
|
||||
Packs are surface-complete CRUD expansions (not every microversion quirk),
|
||||
so we assert meaningful floors rather than bit-identical counts.
|
||||
"""
|
||||
|
||||
packs = load_series_pack("dalmatian")
|
||||
assert packs["neutron"].operation_count() >= 280
|
||||
assert packs["nova"].operation_count() >= 120
|
||||
neutron_paths = {op.path for op in packs["neutron"].operations}
|
||||
assert "/v2.0/address-groups" in neutron_paths
|
||||
assert "/v2.0/bgp-speakers" in neutron_paths
|
||||
assert "/v2.0/segments" in neutron_paths
|
||||
|
||||
|
||||
def test_coverage_doc_lists_new_services() -> None:
|
||||
doc = Path(__file__).resolve().parents[2] / "docs" / "api_coverage.md"
|
||||
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
|
||||
text = doc.read_text()
|
||||
assert "watcher" in text
|
||||
assert "zaqar" in text
|
||||
assert str(man["operation_count"]) in text
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Unit tests for OpenStack contract packs and loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.openstack.contract_loader import (
|
||||
contracts_root,
|
||||
list_series,
|
||||
load_series_pack,
|
||||
major_for_series,
|
||||
)
|
||||
|
||||
|
||||
def test_all_series_packs_exist() -> None:
|
||||
series = {s["series"] for s in list_series()}
|
||||
assert {"yoga", "antelope", "caracal", "dalmatian"} <= series
|
||||
|
||||
|
||||
def test_dalmatian_core_minimums() -> None:
|
||||
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
|
||||
by_name = {s["name"]: s for s in man["services"]}
|
||||
for svc, minimum in man["min_core_operations"].items():
|
||||
assert by_name[svc]["operation_count"] >= minimum
|
||||
assert man["operation_count"] >= 1300
|
||||
assert man["service_count"] == 28
|
||||
by_name = {s["name"]: s for s in man["services"]}
|
||||
assert "watcher" in by_name
|
||||
assert "zaqar" in by_name
|
||||
assert by_name["neutron"]["operation_count"] >= 250
|
||||
assert by_name["nova"]["operation_count"] >= 110
|
||||
|
||||
|
||||
def test_load_series_pack_operations() -> None:
|
||||
packs = load_series_pack("dalmatian")
|
||||
assert "nova" in packs
|
||||
assert "neutron" in packs
|
||||
assert "watcher" in packs
|
||||
assert "zaqar" in packs
|
||||
nova = packs["nova"]
|
||||
methods = {(op.method, op.path) for op in nova.operations}
|
||||
assert ("GET", "/v2.1/servers") in methods
|
||||
assert ("POST", "/v2.1/servers/{id}/action") in methods
|
||||
assert ("GET", "/v2.1/extensions") in methods
|
||||
assert ("GET", "/v2.0/address-groups") in {
|
||||
(op.method, op.path) for op in packs["neutron"].operations
|
||||
}
|
||||
assert nova.max_microversion is not None
|
||||
|
||||
|
||||
def test_major_mapping() -> None:
|
||||
assert major_for_series("dalmatian") == 9
|
||||
assert major_for_series("yoga") == 6
|
||||
|
||||
|
||||
def test_api_json_files_present() -> None:
|
||||
root = contracts_root() / "dalmatian"
|
||||
services = [p for p in root.iterdir() if p.is_dir()]
|
||||
assert len(services) == 28
|
||||
for svc in services:
|
||||
assert (svc / "api.json").is_file()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Per-path OpenStack contract registration (Proxmox-style)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app.openstack.contract_loader import ensure_loaded, load_series_pack
|
||||
from app.openstack.mount import build_openstack_handlers, mount_openstack_routes
|
||||
from app.openstack.registry import (
|
||||
HandlerRegistry,
|
||||
normalize_path_template,
|
||||
register_specialized_handlers,
|
||||
)
|
||||
from app.openstack.routes import nova
|
||||
from app.openstack.schema_engine import remount_schema_services
|
||||
|
||||
|
||||
def test_normalize_path_template_collapses_param_names() -> None:
|
||||
assert normalize_path_template("/v2.1/servers/{id}") == normalize_path_template(
|
||||
"/v2.1/servers/{server_id}"
|
||||
)
|
||||
assert normalize_path_template("/v1/{account}/{container}/{object}") == normalize_path_template(
|
||||
"/v1/{account}/{container}/{object_name:path}"
|
||||
)
|
||||
|
||||
|
||||
def test_swift_object_handler_resolves_from_contract_path() -> None:
|
||||
registry = build_openstack_handlers()
|
||||
assert registry.get("swift", "/v1/{account}/{container}/{object}", "GET") is not None
|
||||
assert registry.get("swift", "/v1/{account}/{container}/{object}", "PUT") is not None
|
||||
|
||||
|
||||
def test_handler_registry_structural_lookup() -> None:
|
||||
registry = HandlerRegistry()
|
||||
|
||||
async def handler(request): # noqa: ANN001
|
||||
return request
|
||||
|
||||
registry.register("nova", "/v2.1/servers/{server_id}", "GET", handler)
|
||||
found = registry.get("nova", "/v2.1/servers/{id}", "GET")
|
||||
assert found is handler
|
||||
|
||||
|
||||
def test_specialized_handlers_imported_from_nova_router() -> None:
|
||||
registry = HandlerRegistry()
|
||||
count = register_specialized_handlers(registry, "nova", nova.router)
|
||||
assert count > 0
|
||||
assert registry.get("nova", "/v2.1/servers", "GET") is not None
|
||||
assert registry.get("nova", "/v2.1/servers/{id}", "GET") is not None
|
||||
|
||||
|
||||
def test_mount_registers_one_route_per_unique_method_path() -> None:
|
||||
app = FastAPI()
|
||||
mount_openstack_routes(app, series="dalmatian")
|
||||
|
||||
packs = load_series_pack("dalmatian")
|
||||
expected = 0
|
||||
for pack in packs.values():
|
||||
expected += len({(op.method, op.path) for op in pack.operations})
|
||||
|
||||
contract_routes = [
|
||||
route
|
||||
for route in app.router.routes
|
||||
if isinstance(route, APIRoute)
|
||||
and isinstance(route.name, str)
|
||||
and route.name.startswith("os-contract:")
|
||||
]
|
||||
# Contract paths plus specialized-only aliases (trailing slash, PUT tags, …).
|
||||
assert len(contract_routes) >= expected
|
||||
assert app.state.openstack_schema_ops == len(contract_routes)
|
||||
# name format: os-contract:{service}:{METHOD}:{path}
|
||||
mounted_ops = set()
|
||||
for route in contract_routes:
|
||||
rest = route.name[len("os-contract:") :]
|
||||
_service, _, remainder = rest.partition(":")
|
||||
method, _, path = remainder.partition(":")
|
||||
mounted_ops.add((method, path))
|
||||
for pack in packs.values():
|
||||
for op in pack.operations:
|
||||
assert (op.method, op.path) in mounted_ops
|
||||
# No legacy schema-* route names.
|
||||
assert not any(
|
||||
isinstance(getattr(r, "name", None), str) and str(r.name).startswith("schema-")
|
||||
for r in app.router.routes
|
||||
)
|
||||
|
||||
|
||||
def test_remount_preserves_handlers_and_route_count() -> None:
|
||||
app = FastAPI()
|
||||
mount_openstack_routes(app, series="dalmatian")
|
||||
handlers = app.state.openstack_handlers
|
||||
assert isinstance(handlers, HandlerRegistry)
|
||||
before = app.state.openstack_schema_ops
|
||||
|
||||
ensure_loaded("caracal")
|
||||
summary = remount_schema_services(app, "caracal")
|
||||
assert app.state.openstack_handlers is handlers
|
||||
assert summary["routes_mounted"] == app.state.openstack_schema_ops
|
||||
assert app.state.openstack_schema_ops > 0
|
||||
# Switching series rebuilds routes; count may differ by series deltas.
|
||||
assert isinstance(before, int)
|
||||
|
||||
|
||||
def test_build_openstack_handlers_covers_core_services() -> None:
|
||||
registry = build_openstack_handlers()
|
||||
for service in ("keystone", "nova", "neutron", "glance", "cinder"):
|
||||
keys = [k for k in registry.keys() if k[0] == service]
|
||||
assert keys, f"expected handlers for {service}"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Integration tests for OpenStack demo cloud seed (requires PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from app.openstack.demo_cloud import (
|
||||
DEMO_PROFILE,
|
||||
DEMO_SERVER_COUNT,
|
||||
clear_openstack_state,
|
||||
openstack_demo_summary,
|
||||
seed_openstack_demo,
|
||||
)
|
||||
from app.openstack.seed import seed_openstack
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _dsn() -> str:
|
||||
return os.environ.get(
|
||||
"TEST_DATABASE_URL",
|
||||
os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://openstack:openstack@127.0.0.1:5433/openstack_simulator",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn():
|
||||
try:
|
||||
connection = await asyncpg.connect(_dsn())
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"postgres unavailable: {exc}")
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_demo_seed_roundtrip(conn: asyncpg.Connection) -> None:
|
||||
await seed_openstack_demo(conn)
|
||||
summary = await openstack_demo_summary(conn)
|
||||
assert summary["loaded"] is True
|
||||
assert summary["servers"] == DEMO_SERVER_COUNT
|
||||
assert summary["hypervisors"] == 16
|
||||
assert summary["projects"] == 5
|
||||
assert summary["volumes"] == 600
|
||||
assert summary["profile"] == DEMO_PROFILE
|
||||
|
||||
await clear_openstack_state(conn)
|
||||
result = await seed_openstack(conn)
|
||||
assert result["profile"] == "minimal"
|
||||
summary = await openstack_demo_summary(conn)
|
||||
assert summary["loaded"] is False
|
||||
assert summary["servers"] == 1
|
||||
assert summary["profile"] == "minimal"
|
||||
|
||||
# Restore demo so a shared lab DB stays usable after the test.
|
||||
await seed_openstack_demo(conn)
|
||||
summary = await openstack_demo_summary(conn)
|
||||
assert summary["loaded"] is True
|
||||
assert summary["servers"] == DEMO_SERVER_COUNT
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Path-based OpenStack service dispatch (WebUI on Keystone port)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.openstack.dispatch import resolve_service, resolve_service_from_path
|
||||
|
||||
|
||||
def test_path_maps_core_services() -> None:
|
||||
assert resolve_service_from_path("/v2.1/servers") == "nova"
|
||||
assert resolve_service_from_path("/v2.0/networks") == "neutron"
|
||||
assert resolve_service_from_path("/v2/images") == "glance"
|
||||
assert resolve_service_from_path("/v3/volumes") == "cinder"
|
||||
assert resolve_service_from_path("/v3/auth/tokens") == "keystone"
|
||||
assert resolve_service_from_path("/v3/projects") == "keystone"
|
||||
assert resolve_service_from_path("/v1/nodes") == "ironic"
|
||||
assert resolve_service_from_path("/v2/lbaas/loadbalancers") == "octavia"
|
||||
assert resolve_service_from_path("/resource_providers") == "placement"
|
||||
|
||||
|
||||
def test_keystone_port_overrides_to_nova_path() -> None:
|
||||
service = resolve_service(
|
||||
{"x-openstack-service": "keystone", "x-forwarded-port": "5000"},
|
||||
"/v2.1/servers/detail",
|
||||
)
|
||||
assert service == "nova"
|
||||
|
||||
|
||||
def test_route_service_header_wins() -> None:
|
||||
service = resolve_service(
|
||||
{
|
||||
"x-openstack-service": "keystone",
|
||||
"x-openstack-route-service": "cinder",
|
||||
"x-forwarded-port": "5000",
|
||||
},
|
||||
"/v3/limits",
|
||||
)
|
||||
assert service == "cinder"
|
||||
|
||||
|
||||
def test_auth_path_ignores_stale_route_service() -> None:
|
||||
service = resolve_service(
|
||||
{
|
||||
"x-openstack-service": "keystone",
|
||||
"x-openstack-route-service": "cinder",
|
||||
"x-forwarded-port": "5000",
|
||||
},
|
||||
"/v3/auth/tokens",
|
||||
)
|
||||
assert service == "keystone"
|
||||
|
||||
|
||||
def test_dedicated_nova_port_keeps_nova() -> None:
|
||||
service = resolve_service(
|
||||
{"x-openstack-service": "nova", "x-forwarded-port": "8774"},
|
||||
"/v2.1/servers",
|
||||
)
|
||||
assert service == "nova"
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Pack-driven surface seed covers every contract resource_type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.openstack.pack_seed import iter_pack_resource_types
|
||||
|
||||
|
||||
def test_iter_pack_resource_types_covers_schema_services() -> None:
|
||||
types = iter_pack_resource_types()
|
||||
assert len(types) >= 200
|
||||
expected = {
|
||||
("barbican", "secret"),
|
||||
("barbican", "container"),
|
||||
("manila", "share"),
|
||||
("manila", "share_type"),
|
||||
("watcher", "goal"),
|
||||
("zun", "host"),
|
||||
("cloudkitty", "dataframes"),
|
||||
("designate", "zone"),
|
||||
}
|
||||
missing = expected - types
|
||||
assert not missing, missing
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit tests for OpenStack pagination helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.openstack.paging import paginate_rows, parse_limit
|
||||
|
||||
|
||||
def _request(query: str = "") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/v2.1/servers",
|
||||
"raw_path": b"/v2.1/servers",
|
||||
"query_string": query.encode(),
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 123),
|
||||
"server": ("test", 80),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
def test_parse_limit_clamps() -> None:
|
||||
assert parse_limit(_request("")) == 0
|
||||
assert parse_limit(_request("limit=25")) == 25
|
||||
assert parse_limit(_request("limit=99999"), maximum=100) == 100
|
||||
|
||||
|
||||
def test_paginate_rows_marker_and_next_link() -> None:
|
||||
rows = [{"id": f"id-{i}"} for i in range(10)]
|
||||
page, links = paginate_rows(
|
||||
rows,
|
||||
_request("limit=3&marker=id-2"),
|
||||
id_attr=lambda r: r["id"],
|
||||
)
|
||||
assert [r["id"] for r in page] == ["id-3", "id-4", "id-5"]
|
||||
assert links and links[0]["rel"] == "next"
|
||||
assert "marker=id-5" in links[0]["href"]
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Series packs must differ across Yoga → Dalmatian."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tools.os_api_inventory.catalog import build_all_operations
|
||||
from tools.os_api_inventory.series_deltas import filter_ops_for_series, series_index
|
||||
|
||||
|
||||
def test_series_operation_counts_increase() -> None:
|
||||
all_ops = build_all_operations()
|
||||
flat = [op for ops in all_ops.values() for op in ops]
|
||||
counts = {
|
||||
series: len(filter_ops_for_series(flat, series))
|
||||
for series in ("yoga", "antelope", "caracal", "dalmatian")
|
||||
}
|
||||
assert counts["yoga"] < counts["antelope"] < counts["caracal"] < counts["dalmatian"]
|
||||
|
||||
|
||||
def test_dalmatian_includes_yoga() -> None:
|
||||
nova = build_all_operations()["nova"]
|
||||
yoga_ids = {op["operation_id"] for op in filter_ops_for_series(nova, "yoga")}
|
||||
dal_ids = {op["operation_id"] for op in filter_ops_for_series(nova, "dalmatian")}
|
||||
assert yoga_ids <= dal_ids
|
||||
assert series_index("yoga") < series_index("dalmatian")
|
||||
Reference in New Issue
Block a user