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:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
@@ -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