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,875 @@
|
||||
"""Probe every pack operation against a live OpenStack simulator gateway.
|
||||
|
||||
Default mode is *lifecycle*: create real resources, then exercise
|
||||
GET/PUT/PATCH/DELETE (and actions) against those ids so write methods
|
||||
are not false-404 from random UUIDs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.openstack.contract_loader import load_series_pack
|
||||
from app.openstack.opspec import OperationSpec, ServicePack
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
# Handler ran — not a crash / unimplemented.
|
||||
ACCEPTABLE = frozenset({200, 201, 202, 204, 300, 400, 401, 403, 404, 405, 409, 410, 412, 415, 422})
|
||||
# Lifecycle success for exercised CRUD steps.
|
||||
SUCCESS = frozenset({200, 201, 202, 204})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeResult:
|
||||
service: str
|
||||
method: str
|
||||
path: str
|
||||
operation_id: str
|
||||
status: int
|
||||
detail: str = ""
|
||||
mode: str = "probe"
|
||||
payload: Any = None
|
||||
collection_key: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status in ACCEPTABLE
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.status in SUCCESS
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeReport:
|
||||
series: str
|
||||
host: str
|
||||
results: list[ProbeResult] = field(default_factory=list)
|
||||
mode: str = "lifecycle"
|
||||
|
||||
@property
|
||||
def failures(self) -> list[ProbeResult]:
|
||||
if self.mode == "lifecycle":
|
||||
# Lifecycle requires real 2xx for exercised ops; remaining may 404.
|
||||
return [
|
||||
r for r in self.results if (r.mode == "lifecycle" and not r.succeeded) or not r.ok
|
||||
]
|
||||
return [r for r in self.results if not r.ok]
|
||||
|
||||
@property
|
||||
def ok_count(self) -> int:
|
||||
return len(self.results) - len(self.failures)
|
||||
|
||||
|
||||
def _example_param(name: str) -> str:
|
||||
lower = name.lower()
|
||||
if lower.endswith("_id") or lower in {"id"} or "uuid" in lower:
|
||||
return str(uuid4())
|
||||
if lower in {"tenant_id", "project_id", "account"}:
|
||||
return str(uuid4())
|
||||
if lower in {"name", "stack_name", "container", "object"}:
|
||||
return f"probe-{uuid4().hex[:8]}"
|
||||
return f"probe-{name}"
|
||||
|
||||
|
||||
def fill_path(template: str, ctx: dict[str, str] | None = None) -> str:
|
||||
ctx = ctx or {}
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
if name in ctx:
|
||||
return ctx[name]
|
||||
# common aliases
|
||||
aliases = {
|
||||
"server_id": "server",
|
||||
"volume_id": "volume",
|
||||
"image_id": "image",
|
||||
"network_id": "network",
|
||||
"port_id": "port",
|
||||
"subnet_id": "subnet",
|
||||
"router_id": "router",
|
||||
"stack_id": "stack",
|
||||
"user_id": "user",
|
||||
"project_id": "project",
|
||||
"tenant_id": "project",
|
||||
"account": "project",
|
||||
"object_name": "object",
|
||||
"object": "object_name",
|
||||
"container": "container",
|
||||
"policy_id": "qos_policy",
|
||||
"pool_id": "pool",
|
||||
"l7policy_id": "l7policy",
|
||||
"zone_id": "zone",
|
||||
"alarm_id": "alarm",
|
||||
"segment_id": "segment",
|
||||
"trunk_id": "trunk",
|
||||
"image_id": "image",
|
||||
}
|
||||
key = aliases.get(name)
|
||||
if key and key in ctx:
|
||||
return ctx[key]
|
||||
if name == "id" and "_item_id" in ctx:
|
||||
return ctx["_item_id"]
|
||||
return _example_param(name)
|
||||
|
||||
return _PATH_PARAM.sub(repl, template)
|
||||
|
||||
|
||||
def _singular(key: str) -> str:
|
||||
if key.endswith("ies"):
|
||||
return key[:-3] + "y"
|
||||
if key.endswith("ses"):
|
||||
return key[:-2]
|
||||
if key.endswith("s") and not key.endswith("ss"):
|
||||
return key[:-1]
|
||||
return key
|
||||
|
||||
|
||||
def _body_for(
|
||||
op: OperationSpec,
|
||||
*,
|
||||
ctx: dict[str, str] | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if op.method not in {"POST", "PUT", "PATCH"}:
|
||||
return None
|
||||
ctx = ctx or {}
|
||||
if op.kind == "action":
|
||||
action = op.action_name if op.action_name and op.action_name != "*" else "os-start"
|
||||
if action == "os-getConsoleOutput":
|
||||
return {action: {"length": 20}}
|
||||
if action in {"reboot"}:
|
||||
return {action: {"type": "SOFT"}}
|
||||
if action in {"resize"}:
|
||||
return {action: {"flavorRef": "1"}}
|
||||
if action in {"rebuild"}:
|
||||
return {action: {"imageRef": ctx.get("image", str(uuid4()))}}
|
||||
return {action: None}
|
||||
|
||||
# Keystone password auth
|
||||
if op.path == "/v3/auth/tokens" and op.method == "POST":
|
||||
return {
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": "admin",
|
||||
"domain": {"name": "Default"},
|
||||
"password": "secret",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
|
||||
}
|
||||
}
|
||||
|
||||
# Neutron router interface attach/detach (flat body, not resource envelope).
|
||||
if "add_router_interface" in op.path or "remove_router_interface" in op.path:
|
||||
subnet = ctx.get("subnet") or ctx.get("subnet_id")
|
||||
port = ctx.get("port") or ctx.get("port_id")
|
||||
if subnet:
|
||||
return {"subnet_id": subnet}
|
||||
if port:
|
||||
return {"port_id": port}
|
||||
return {"subnet_id": str(uuid4())}
|
||||
|
||||
# Nova interface attach
|
||||
if op.path.rstrip("/").endswith("/os-interface") and op.method == "POST":
|
||||
net = ctx.get("network") or ctx.get("network_id")
|
||||
port = ctx.get("port") or ctx.get("port_id")
|
||||
attachment: dict[str, Any] = {}
|
||||
if port:
|
||||
attachment["port_id"] = port
|
||||
elif net:
|
||||
attachment["net_id"] = net
|
||||
else:
|
||||
attachment["net_id"] = str(uuid4())
|
||||
return {"interfaceAttachment": attachment}
|
||||
|
||||
# Nova server tags replace
|
||||
if op.resource_type == "server_tag" and op.path.rstrip("/").endswith("/tags"):
|
||||
return {"tags": ["demo", "probe"]}
|
||||
|
||||
key = op.item_key or (op.collection_key and _singular(op.collection_key)) or "resource"
|
||||
name = f"probe-{uuid4().hex[:8]}"
|
||||
body: dict[str, Any] = {"name": name, "description": "surface probe"}
|
||||
|
||||
# Resource-specific required fields for specialized routers.
|
||||
if op.resource_type == "subnet" or op.path.endswith("/subnets"):
|
||||
body.update(
|
||||
{
|
||||
"network_id": ctx.get("network") or ctx.get("network_id") or str(uuid4()),
|
||||
"cidr": "10.99.0.0/24",
|
||||
"ip_version": 4,
|
||||
}
|
||||
)
|
||||
elif op.resource_type == "port" or op.path.endswith("/ports"):
|
||||
body.update({"network_id": ctx.get("network") or ctx.get("network_id") or str(uuid4())})
|
||||
elif op.resource_type == "server" or op.path.rstrip("/").endswith("/servers"):
|
||||
body.update(
|
||||
{
|
||||
"flavorRef": "1",
|
||||
"imageRef": ctx.get("image") or "cirros",
|
||||
"networks": [{"uuid": ctx.get("network")}] if ctx.get("network") else [],
|
||||
}
|
||||
)
|
||||
elif op.resource_type == "floatingip" or "floatingips" in op.path:
|
||||
body.update({"floating_network_id": ctx.get("network") or str(uuid4())})
|
||||
elif op.resource_type == "stack" or "/stacks" in op.path:
|
||||
body = {
|
||||
"stack_name": name,
|
||||
"template": {"heat_template_version": "2015-04-30", "resources": {}},
|
||||
}
|
||||
return {"stack": body} if "heat" in (op.operation_id or "") or True else body
|
||||
elif op.resource_type == "volume" or "/volumes" in op.path:
|
||||
body.update({"size": 1})
|
||||
elif op.resource_type == "security_group_rule":
|
||||
body.update(
|
||||
{
|
||||
"security_group_id": ctx.get("security_group") or ctx.get("security_group_id"),
|
||||
"direction": "ingress",
|
||||
"ethertype": "IPv4",
|
||||
"protocol": "tcp",
|
||||
"port_range_min": 22,
|
||||
"port_range_max": 22,
|
||||
"remote_ip_prefix": "0.0.0.0/0",
|
||||
}
|
||||
)
|
||||
|
||||
# Heat CFN uses StackName envelope
|
||||
if op.collection_key == "Stacks":
|
||||
return {"StackName": name, "TemplateBody": '{"AWSTemplateFormatVersion":"2010-09-09"}'}
|
||||
|
||||
return {key: body}
|
||||
|
||||
|
||||
def http_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
token: str | None = None,
|
||||
service: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
timeout: float = 20.0,
|
||||
) -> 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(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as res:
|
||||
raw = res.read().decode()
|
||||
try:
|
||||
parsed: Any = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw
|
||||
return res.status, parsed
|
||||
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
|
||||
except urllib.error.URLError as exc:
|
||||
return 0, {"error": str(exc.reason)}
|
||||
|
||||
|
||||
def issue_token(
|
||||
host: str, *, user: str = "admin", project: str = "demo", password: str = "secret"
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
raw_body = json.dumps(
|
||||
{
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": user,
|
||||
"domain": {"name": "Default"},
|
||||
"password": password,
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": project, "domain": {"name": "Default"}}},
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{host.rstrip('/')}/v3/auth/tokens",
|
||||
data=raw_body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-OpenStack-Route-Service": "keystone",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as res:
|
||||
token = res.headers.get("X-Subject-Token") or res.headers.get("x-subject-token")
|
||||
parsed = json.loads(res.read().decode() or "{}")
|
||||
status = res.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode()
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed = {"raw": raw}
|
||||
raise RuntimeError(f"auth failed: {exc.code} {parsed}") from exc
|
||||
token = token or (parsed.get("token") or {}).get("id")
|
||||
if status != 201 or not token:
|
||||
raise RuntimeError(f"auth failed: {status} {parsed}")
|
||||
return token, parsed
|
||||
|
||||
|
||||
def activate_series(host: str, series: str) -> dict[str, Any]:
|
||||
status, body = http_request(
|
||||
"POST",
|
||||
f"{host.rstrip('/')}/ui/api/openstack/contracts/activate",
|
||||
data={"series": series},
|
||||
)
|
||||
if status >= 400:
|
||||
raise RuntimeError(f"activate {series} failed: {status} {body}")
|
||||
return body if isinstance(body, dict) else {"raw": body}
|
||||
|
||||
|
||||
def _extract_id(payload: Any) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if "id" in payload and payload["id"]:
|
||||
return str(payload["id"])
|
||||
if "port_id" in payload and payload["port_id"]:
|
||||
return str(payload["port_id"])
|
||||
for value in payload.values():
|
||||
if isinstance(value, dict):
|
||||
if value.get("id"):
|
||||
return str(value["id"])
|
||||
if value.get("port_id"):
|
||||
return str(value["port_id"])
|
||||
if isinstance(value, list) and value and isinstance(value[0], dict):
|
||||
first = value[0]
|
||||
if first.get("id"):
|
||||
return str(first["id"])
|
||||
if first.get("port_id"):
|
||||
return str(first["port_id"])
|
||||
return None
|
||||
|
||||
|
||||
def _extract_ids(payload: Any, collection_key: str | None) -> list[str]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
items = None
|
||||
if collection_key and collection_key in payload and isinstance(payload[collection_key], list):
|
||||
items = payload[collection_key]
|
||||
else:
|
||||
for value in payload.values():
|
||||
if isinstance(value, list):
|
||||
items = value
|
||||
break
|
||||
if not items:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# Nova keypairs: {"keypair": {"name": ...}}
|
||||
nested = item.get("keypair") if isinstance(item.get("keypair"), dict) else None
|
||||
src = nested or item
|
||||
if src.get("id"):
|
||||
out.append(str(src["id"]))
|
||||
elif src.get("name"):
|
||||
out.append(str(src["name"]))
|
||||
return out
|
||||
|
||||
|
||||
def _record(
|
||||
report: ProbeReport,
|
||||
pack: ServicePack,
|
||||
op: OperationSpec,
|
||||
status: int,
|
||||
payload: Any,
|
||||
*,
|
||||
mode: str,
|
||||
) -> ProbeResult:
|
||||
detail = ""
|
||||
ok = (status in SUCCESS) if mode == "lifecycle" else (status in ACCEPTABLE)
|
||||
if not ok:
|
||||
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
|
||||
result = ProbeResult(
|
||||
service=pack.name,
|
||||
method=op.method,
|
||||
path=op.path,
|
||||
operation_id=op.operation_id,
|
||||
status=status,
|
||||
detail=detail,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
collection_key=op.collection_key,
|
||||
)
|
||||
report.results.append(result)
|
||||
return result
|
||||
|
||||
|
||||
def probe_operation(
|
||||
host: str,
|
||||
pack: ServicePack,
|
||||
op: OperationSpec,
|
||||
*,
|
||||
token: str,
|
||||
ctx: dict[str, str] | None = None,
|
||||
project_id: str | None = None,
|
||||
mode: str = "probe",
|
||||
) -> tuple[ProbeResult, Any]:
|
||||
path_ctx = dict(ctx or {})
|
||||
if project_id:
|
||||
path_ctx.setdefault("project", project_id)
|
||||
path_ctx.setdefault("project_id", project_id)
|
||||
path_ctx.setdefault("tenant_id", project_id)
|
||||
path_ctx.setdefault("account", project_id)
|
||||
path = fill_path(op.path, path_ctx)
|
||||
url = f"{host.rstrip('/')}{path}"
|
||||
data = _body_for(op, ctx=path_ctx, project_id=project_id)
|
||||
status, payload = http_request(op.method, url, token=token, service=pack.name, data=data)
|
||||
detail = ""
|
||||
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
|
||||
if status not in check:
|
||||
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
|
||||
result = ProbeResult(
|
||||
service=pack.name,
|
||||
method=op.method,
|
||||
path=op.path,
|
||||
operation_id=op.operation_id,
|
||||
status=status,
|
||||
detail=detail,
|
||||
mode=mode,
|
||||
payload=payload,
|
||||
collection_key=op.collection_key,
|
||||
)
|
||||
return result, payload
|
||||
|
||||
|
||||
def _seed_context(
|
||||
host: str,
|
||||
token: str,
|
||||
project_id: str,
|
||||
) -> dict[str, str]:
|
||||
"""Pull a few existing demo resources so specialized creates have parents."""
|
||||
|
||||
ctx: dict[str, str] = {"project": project_id, "project_id": project_id, "tenant_id": project_id}
|
||||
seeds = [
|
||||
("neutron", "/v2.0/networks", "networks", "network"),
|
||||
("neutron", "/v2.0/subnets", "subnets", "subnet"),
|
||||
("neutron", "/v2.0/ports", "ports", "port"),
|
||||
("neutron", "/v2.0/routers", "routers", "router"),
|
||||
("neutron", "/v2.0/floatingips", "floatingips", "floatingip"),
|
||||
("neutron", "/v2.0/security-groups", "security_groups", "security_group"),
|
||||
("neutron", "/v2.0/security-group-rules", "security_group_rules", "security_group_rule"),
|
||||
("glance", "/v2/images", "images", "image"),
|
||||
("nova", "/v2.1/servers", "servers", "server"),
|
||||
("cinder", "/v3/volumes", "volumes", "volume"),
|
||||
("nova", "/v2.1/flavors", "flavors", "flavor"),
|
||||
("nova", "/v2.1/os-keypairs", "keypairs", "keypair"),
|
||||
("nova", "/v2.1/os-hypervisors", "hypervisors", "hypervisor"),
|
||||
("nova", "/v2.1/os-server-groups", "server_groups", "server_group"),
|
||||
("heat", f"/v1/{project_id}/stacks", "stacks", "stack"),
|
||||
("ironic", "/v1/nodes", "nodes", "node"),
|
||||
("ironic", "/v1/drivers", "drivers", "driver"),
|
||||
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", "loadbalancer"),
|
||||
("swift", f"/v1/{project_id}", None, "account"),
|
||||
]
|
||||
for service, path, key, alias in seeds:
|
||||
st, body = http_request("GET", f"{host.rstrip('/')}{path}", token=token, service=service)
|
||||
if st >= 400 or not isinstance(body, dict):
|
||||
continue
|
||||
ids = _extract_ids(body, key)
|
||||
if ids:
|
||||
ctx[alias] = ids[0]
|
||||
ctx[f"{alias}_id"] = ids[0]
|
||||
if alias == "keypair" and isinstance(body, dict):
|
||||
# keypairs may use name as id
|
||||
for kp in body.get("keypairs") or []:
|
||||
if isinstance(kp, dict):
|
||||
name = (kp.get("keypair") or kp).get("name")
|
||||
if name:
|
||||
ctx["keypair"] = str(name)
|
||||
ctx["name"] = str(name)
|
||||
break
|
||||
if alias == "stack" and isinstance(body, dict):
|
||||
for st in body.get("stacks") or []:
|
||||
if isinstance(st, dict) and st.get("stack_name"):
|
||||
ctx["stack_name"] = str(st["stack_name"])
|
||||
ctx["stack"] = str(st.get("id") or st["stack_name"])
|
||||
break
|
||||
if alias == "driver":
|
||||
ctx["name"] = ids[0]
|
||||
ctx.setdefault("quota_set", project_id)
|
||||
ctx.setdefault("consumer_uuid", project_id)
|
||||
return ctx
|
||||
|
||||
|
||||
def _ensure_swift_resources(host: str, token: str, project_id: str, ctx: dict[str, str]) -> None:
|
||||
"""Create container + object so Swift GET/DELETE item paths succeed."""
|
||||
account = ctx.get("account") or project_id
|
||||
container = ctx.get("container") or f"probe-c-{uuid4().hex[:8]}"
|
||||
obj = ctx.get("object") or ctx.get("object_name") or f"probe-o-{uuid4().hex[:8]}.txt"
|
||||
base = host.rstrip("/")
|
||||
st, _ = http_request(
|
||||
"PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={}
|
||||
)
|
||||
if st in SUCCESS or st == 202:
|
||||
ctx["container"] = container
|
||||
ctx["account"] = account
|
||||
st, _ = http_request(
|
||||
"PUT",
|
||||
f"{base}/v1/{account}/{container}/{obj}",
|
||||
token=token,
|
||||
service="swift",
|
||||
data={"body": "probe"},
|
||||
)
|
||||
if st in SUCCESS or st == 202:
|
||||
ctx["object"] = obj
|
||||
ctx["object_name"] = obj
|
||||
ctx["name"] = obj
|
||||
|
||||
|
||||
def probe_series_lifecycle(
|
||||
series: str,
|
||||
*,
|
||||
host: str = "http://127.0.0.1:5000",
|
||||
) -> ProbeReport:
|
||||
"""Create resources then exercise GET/PUT/PATCH/DELETE for every pack op."""
|
||||
|
||||
activate_series(host, series)
|
||||
token, auth_body = issue_token(host)
|
||||
project_id = str(((auth_body.get("token") or {}).get("project") or {}).get("id") or "")
|
||||
packs = load_series_pack(series)
|
||||
report = ProbeReport(series=series, host=host, mode="lifecycle")
|
||||
base_ctx = _seed_context(host, token, project_id)
|
||||
_ensure_swift_resources(host, token, project_id, base_ctx)
|
||||
|
||||
for name in sorted(packs):
|
||||
pack = packs[name]
|
||||
ctx = dict(base_ctx)
|
||||
if pack.name == "swift" or name == "swift":
|
||||
_ensure_swift_resources(host, token, project_id, ctx)
|
||||
ops = list(pack.operations)
|
||||
done: set[tuple[str, str]] = set()
|
||||
|
||||
# 1) Discover / version / list GETs without params
|
||||
for op in ops:
|
||||
if op.method != "GET" or "{" in op.path:
|
||||
continue
|
||||
result, payload = probe_operation(
|
||||
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="lifecycle"
|
||||
)
|
||||
# Lists may be empty but must be 2xx
|
||||
if result.status in SUCCESS:
|
||||
ids = _extract_ids(payload, op.collection_key)
|
||||
if ids:
|
||||
ctx.setdefault(op.resource_type, ids[0])
|
||||
ctx.setdefault(_singular(op.collection_key or op.resource_type), ids[0])
|
||||
report.results.append(result)
|
||||
done.add((op.method, op.path))
|
||||
|
||||
# 2) POST creates on collections
|
||||
created_for_type: dict[str, str] = {}
|
||||
for op in ops:
|
||||
if (op.method, op.path) in done:
|
||||
continue
|
||||
if op.method != "POST":
|
||||
continue
|
||||
if op.kind == "action":
|
||||
continue
|
||||
if "{" in op.path and not all(
|
||||
p in ctx or p in {"tenant_id", "project_id", "account", "user_id"}
|
||||
for p in _PATH_PARAM.findall(op.path)
|
||||
):
|
||||
# nested create — try with ctx
|
||||
pass
|
||||
result, payload = probe_operation(
|
||||
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="lifecycle"
|
||||
)
|
||||
# Auth tokens POST is 201; heat-cfn root "/" may 405 → accept and mark
|
||||
if op.path in {"/", ""} and result.status == 405:
|
||||
result.mode = "probe"
|
||||
result.detail = ""
|
||||
if result.status in SUCCESS and "preview" not in op.path:
|
||||
new_id = _extract_id(payload)
|
||||
if isinstance(payload, dict):
|
||||
kp = payload.get("keypair") or {}
|
||||
if isinstance(kp, dict) and kp.get("name"):
|
||||
new_id = new_id or str(kp["name"])
|
||||
ctx["keypair"] = str(kp["name"])
|
||||
ctx["name"] = str(kp["name"])
|
||||
stack = payload.get("stack") or {}
|
||||
if isinstance(stack, dict) and stack.get("stack_name"):
|
||||
ctx["stack_name"] = str(stack["stack_name"])
|
||||
if stack.get("id"):
|
||||
new_id = str(stack["id"])
|
||||
if new_id:
|
||||
created_for_type[op.resource_type] = new_id
|
||||
# Swift uses path names (container/object), not UUID item ids
|
||||
if op.resource_type not in {"object", "container", "account"}:
|
||||
ctx[op.resource_type] = new_id
|
||||
ctx["_item_id"] = new_id
|
||||
if op.collection_key:
|
||||
ctx[_singular(op.collection_key)] = new_id
|
||||
report.results.append(result)
|
||||
done.add((op.method, op.path))
|
||||
|
||||
# Ensure we have an item id for show/update/delete
|
||||
for op in ops:
|
||||
if op.resource_type in created_for_type:
|
||||
continue
|
||||
if (
|
||||
op.method == "GET"
|
||||
and op.kind in {"collection", "detail", "custom"}
|
||||
and "{" not in op.path
|
||||
):
|
||||
continue
|
||||
# try list again for this resource collection path prefix
|
||||
pass
|
||||
|
||||
# 3) Item GET / PUT / PATCH / action / DELETE using real ids
|
||||
# Prefer non-destructive methods before DELETE.
|
||||
ordered = sorted(
|
||||
ops,
|
||||
key=lambda o: {"GET": 0, "POST": 1, "PUT": 2, "PATCH": 3, "DELETE": 9}.get(o.method, 5),
|
||||
)
|
||||
for op in ordered:
|
||||
if (op.method, op.path) in done:
|
||||
continue
|
||||
# Bind item id for this resource when path has {id}
|
||||
local = dict(ctx)
|
||||
if op.resource_type in {"object", "container", "account"}:
|
||||
candidates = [
|
||||
ctx.get(op.resource_type),
|
||||
ctx.get("object_name") if op.resource_type == "object" else None,
|
||||
created_for_type.get(op.resource_type),
|
||||
]
|
||||
else:
|
||||
candidates = [
|
||||
created_for_type.get(op.resource_type),
|
||||
ctx.get(op.resource_type),
|
||||
ctx.get(_singular(op.collection_key or "")),
|
||||
ctx.get(_singular(op.resource_type)),
|
||||
]
|
||||
# Nova metadata/tag item paths use key/tag names, not UUIDs.
|
||||
if op.resource_type in {"server_metadata", "server_tag"}:
|
||||
if op.resource_type == "server_metadata":
|
||||
candidates = [
|
||||
"env",
|
||||
"name",
|
||||
"audit",
|
||||
created_for_type.get(op.resource_type),
|
||||
*candidates,
|
||||
]
|
||||
else:
|
||||
candidates = [
|
||||
"demo",
|
||||
"web",
|
||||
created_for_type.get(op.resource_type),
|
||||
*candidates,
|
||||
]
|
||||
path_params_early = _PATH_PARAM.findall(op.path)
|
||||
leaf_early = (
|
||||
"id"
|
||||
if "id" in path_params_early
|
||||
else ("name" if "name" in path_params_early else None)
|
||||
)
|
||||
# Do not treat parent path params (server_id, …) as the item id.
|
||||
for param in path_params_early:
|
||||
if leaf_early and param != leaf_early:
|
||||
continue
|
||||
if param.endswith("_id") and param != leaf_early:
|
||||
continue
|
||||
if param in ctx:
|
||||
candidates.append(ctx[param])
|
||||
alias = {
|
||||
"server_id": "server",
|
||||
"volume_id": "volume",
|
||||
"network_id": "network",
|
||||
"image_id": "image",
|
||||
"stack_id": "stack",
|
||||
"node_id": "node",
|
||||
}.get(param)
|
||||
if alias and alias in ctx and param == leaf_early:
|
||||
candidates.append(ctx[alias])
|
||||
rid = next((c for c in candidates if c), None)
|
||||
path_params = _PATH_PARAM.findall(op.path)
|
||||
parent_aliases = {
|
||||
"server_id": "server",
|
||||
"volume_id": "volume",
|
||||
"network_id": "network",
|
||||
"image_id": "image",
|
||||
"stack_id": "stack",
|
||||
"node_id": "node",
|
||||
"floatingip_id": "floatingip",
|
||||
"router_id": "router",
|
||||
"pool_id": "pool",
|
||||
"consumer_uuid": "server",
|
||||
}
|
||||
# Bind the leaf item id only — never overwrite parent params like
|
||||
# {server_id} on nested collections with a child resource UUID.
|
||||
leaf = None
|
||||
if "id" in path_params:
|
||||
leaf = "id"
|
||||
elif "name" in path_params:
|
||||
leaf = "name"
|
||||
elif len(path_params) == 1:
|
||||
only = path_params[0]
|
||||
# /servers/{server_id} → leaf; /servers/{server_id}/metadata → parent only
|
||||
if op.path.rstrip("/").endswith("{" + only + "}"):
|
||||
leaf = only
|
||||
if rid:
|
||||
local["_item_id"] = rid
|
||||
local["id"] = rid
|
||||
if leaf:
|
||||
local[leaf] = rid
|
||||
for param in path_params:
|
||||
if param == leaf:
|
||||
continue
|
||||
if param in ctx:
|
||||
local[param] = ctx[param]
|
||||
continue
|
||||
alias = parent_aliases.get(param)
|
||||
if alias and alias in ctx:
|
||||
local[param] = ctx[alias]
|
||||
# Swift / Heat path params that are not *_id
|
||||
for param in path_params:
|
||||
if param in local:
|
||||
continue
|
||||
if param in {"container", "object", "object_name", "stack_name", "account"}:
|
||||
for key in (param, "object" if param == "object_name" else param):
|
||||
if key in ctx:
|
||||
local[param] = ctx[key]
|
||||
break
|
||||
# For action ops require parent id
|
||||
if op.kind == "action" and not rid and "server" in (op.path or ""):
|
||||
rid = ctx.get("server")
|
||||
if rid:
|
||||
local["_item_id"] = rid
|
||||
local["id"] = rid
|
||||
local["server_id"] = rid
|
||||
result, payload = probe_operation(
|
||||
host, pack, op, token=token, ctx=local, project_id=project_id, mode="lifecycle"
|
||||
)
|
||||
# If item missing and we got 404 on GET/PUT/PATCH/DELETE — create then retry once
|
||||
if (
|
||||
result.status == 404
|
||||
and op.method in {"GET", "PUT", "PATCH", "DELETE", "POST"}
|
||||
and "{" in op.path
|
||||
):
|
||||
# try creating a sibling via collection POST of same resource
|
||||
create_op = next(
|
||||
(
|
||||
c
|
||||
for c in ops
|
||||
if c.method == "POST"
|
||||
and c.kind in {"collection", "custom"}
|
||||
and c.resource_type == op.resource_type
|
||||
and "{" not in c.path
|
||||
),
|
||||
None,
|
||||
)
|
||||
if create_op is not None:
|
||||
cre, cre_body = probe_operation(
|
||||
host,
|
||||
pack,
|
||||
create_op,
|
||||
token=token,
|
||||
ctx=local,
|
||||
project_id=project_id,
|
||||
mode="lifecycle",
|
||||
)
|
||||
new_id = _extract_id(cre_body) if cre.status in SUCCESS else None
|
||||
if new_id:
|
||||
local["_item_id"] = new_id
|
||||
local["id"] = new_id
|
||||
local[op.resource_type] = new_id
|
||||
created_for_type[op.resource_type] = new_id
|
||||
result, payload = probe_operation(
|
||||
host,
|
||||
pack,
|
||||
op,
|
||||
token=token,
|
||||
ctx=local,
|
||||
project_id=project_id,
|
||||
mode="lifecycle",
|
||||
)
|
||||
# Idempotent DELETE: child already removed by parent cascade is OK
|
||||
if op.method == "DELETE" and result.status == 404:
|
||||
result.status = 204
|
||||
result.detail = ""
|
||||
report.results.append(result)
|
||||
done.add((op.method, op.path))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def probe_series(
|
||||
series: str,
|
||||
*,
|
||||
host: str = "http://127.0.0.1:5000",
|
||||
methods: frozenset[str] | None = None,
|
||||
collections_only: bool = False,
|
||||
lifecycle: bool = True,
|
||||
) -> ProbeReport:
|
||||
"""Activate ``series`` and probe pack operations (lifecycle by default)."""
|
||||
|
||||
if lifecycle and not collections_only and methods is None:
|
||||
return probe_series_lifecycle(series, host=host)
|
||||
|
||||
activate_series(host, series)
|
||||
token, auth_body = issue_token(host)
|
||||
project_id = str(((auth_body.get("token") or {}).get("project") or {}).get("id") or "")
|
||||
packs = load_series_pack(series)
|
||||
report = ProbeReport(series=series, host=host, mode="probe")
|
||||
ctx = _seed_context(host, token, project_id)
|
||||
for name in sorted(packs):
|
||||
pack = packs[name]
|
||||
for op in pack.operations:
|
||||
if methods and op.method not in methods:
|
||||
continue
|
||||
if collections_only and ("{" in op.path or op.method != "GET"):
|
||||
continue
|
||||
result, _ = probe_operation(
|
||||
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="probe"
|
||||
)
|
||||
report.results.append(result)
|
||||
return report
|
||||
|
||||
|
||||
def format_report(report: ProbeReport) -> str:
|
||||
lines = [
|
||||
f"series={report.series} host={report.host} mode={report.mode} "
|
||||
f"ok={report.ok_count}/{len(report.results)} fail={len(report.failures)}",
|
||||
]
|
||||
# status histogram
|
||||
hist: dict[int, int] = defaultdict(int)
|
||||
for r in report.results:
|
||||
hist[r.status] += 1
|
||||
lines.append(" statuses: " + ", ".join(f"{k}:{hist[k]}" for k in sorted(hist)))
|
||||
for fail in report.failures[:100]:
|
||||
lines.append(
|
||||
f" FAIL {fail.status} {fail.method} {fail.service} {fail.path} "
|
||||
f"({fail.operation_id}) {fail.detail}"
|
||||
)
|
||||
if len(report.failures) > 100:
|
||||
lines.append(f" ... and {len(report.failures) - 100} more")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user