Files
openstack-api-simulator/app/openstack/surface_probe.py
T

1046 lines
39 KiB
Python

"""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 urllib.parse import urlparse
from uuid import uuid4
from app.openstack.contract_loader import load_series_pack
from app.openstack.opspec import OperationSpec, ServicePack
from app.openstack.singular import singular as _singular
from app.openstack.surface import all_service_ports
_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})
# OpenStack-API-Version type tokens (service name → header type).
_MV_TYPE = {
"nova": "compute",
"cinder": "volume",
"placement": "placement",
"manila": "share",
"ironic": "baremetal",
}
def gateway_hostname(host: str) -> tuple[str, str]:
"""Return (scheme, hostname) from a Keystone/gateway base URL."""
parsed = urlparse(host if "://" in host else f"http://{host}")
return parsed.scheme or "http", parsed.hostname or "127.0.0.1"
def service_base_url(host: str, service: str, *, port: int | None = None) -> str:
"""Build ``scheme://hostname:<service-port>`` for real OpenStack ports.
``host`` is the Keystone/gateway URL (may use up-local :15000). Service calls
always use the catalog port from ``docs/ports.md`` / ``ServicePack.port``.
"""
scheme, hostname = gateway_hostname(host)
ports = all_service_ports()
svc_port = port if port is not None else ports.get(service)
if svc_port is None:
# Fall back to the host as-is (route-service header still applied).
return host.rstrip("/")
return f"{scheme}://{hostname}:{svc_port}"
def microversion_headers(pack: ServicePack, op: OperationSpec) -> dict[str, str]:
"""Headers when the pack declares a microversion for this service/op."""
ver = op.microversion_min or pack.default_microversion
if not ver:
return {}
typ = _MV_TYPE.get(pack.name) or (pack.typ if pack.typ and pack.typ != pack.name else pack.name)
headers = {"OpenStack-API-Version": f"{typ} {ver}"}
if pack.name == "nova":
headers["X-OpenStack-Nova-API-Version"] = ver
return headers
def count_declared_ops(packs: dict[str, ServicePack]) -> tuple[int, int]:
"""Return (pack_ops, synthetic_head_ops) for coverage arithmetic."""
pack_ops = sum(len(p.operations) for p in packs.values())
head_ops = sum(1 for p in packs.values() for op in p.operations if op.method == "GET")
return pack_ops, head_ops
@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 _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,
headers: dict[str, str] | None = None,
timeout: float = 20.0,
) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode()
hdrs = {"Accept": "application/json"}
if data is not None:
hdrs["Content-Type"] = "application/json"
if token:
hdrs["X-Auth-Token"] = token
if service:
# Fallback when the client cannot reach the real service port.
hdrs["X-OpenStack-Route-Service"] = service
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
raw = res.read().decode() if method.upper() != "HEAD" else ""
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() if method.upper() != "HEAD" else ""
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",
method_override: str | None = None,
) -> 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)
base = service_base_url(host, pack.name, port=pack.port)
url = f"{base}{path}"
method = (method_override or op.method).upper()
data = None if method in {"GET", "HEAD", "DELETE"} else _body_for(op, ctx=path_ctx, project_id=project_id)
mv = microversion_headers(pack, op)
status, payload = http_request(
method,
url,
token=token,
service=pack.name,
data=data,
headers=mv or None,
)
detail = ""
# Synthetic HEAD: accept the same codes as probe (no body expected).
if method == "HEAD":
check = ACCEPTABLE
result_mode = "head"
else:
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
result_mode = mode
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=method,
path=op.path,
operation_id=op.operation_id if method != "HEAD" else f"{op.operation_id}__head",
status=status,
detail=detail,
mode=result_mode,
payload=payload,
collection_key=op.collection_key,
)
return result, payload
def _append_synthetic_heads(
report: ProbeReport,
packs: dict[str, ServicePack],
*,
host: str,
token: str,
project_id: str,
ctx: dict[str, str],
) -> None:
"""Issue HEAD for every pack GET path (contract matrix Layer A)."""
for name in sorted(packs):
pack = packs[name]
for op in pack.operations:
if op.method != "GET":
continue
result, _ = probe_operation(
host,
pack,
op,
token=token,
ctx=ctx,
project_id=project_id,
mode="probe",
method_override="HEAD",
)
report.results.append(result)
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/AUTH_{project_id}", None, "account"),
]
for service, path, key, alias in seeds:
url = f"{service_base_url(host, service)}{path}"
st, body = http_request("GET", url, token=token, service=service)
if st >= 400:
continue
# Swift account listing is a JSON array, not an envelope object.
if service == "swift" and isinstance(body, list) and body:
ctx["account"] = f"AUTH_{project_id}"
first = body[0] if isinstance(body[0], dict) else {}
if first.get("name"):
ctx["container"] = str(first["name"])
# Seed object name from the first container listing when present.
ost, objs = http_request(
"GET",
f"{service_base_url(host, 'swift')}/v1/{ctx['account']}/{ctx['container']}",
token=token,
service="swift",
)
if ost < 400 and isinstance(objs, list) and objs:
oname = objs[0].get("name") if isinstance(objs[0], dict) else None
if oname:
ctx["object"] = str(oname)
ctx["object_name"] = str(oname)
continue
if 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 = service_base_url(host, "swift")
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
# Swift: empty the container before DELETE so the API returns 204
# (409 Conflict for a non-empty container is correct OpenStack behaviour).
if (
op.method == "DELETE"
and pack.name == "swift"
and op.resource_type == "container"
):
acct = local.get("account") or project_id
cname = local.get("container") or local.get("id")
if acct and cname:
base = service_base_url(host, "swift", port=pack.port)
st_list, objs = http_request(
"GET",
f"{base}/v1/{acct}/{cname}",
token=token,
service="swift",
)
if st_list in SUCCESS and isinstance(objs, list):
for obj in objs:
name = obj.get("name") if isinstance(obj, dict) else None
if name:
http_request(
"DELETE",
f"{base}/v1/{acct}/{cname}/{name}",
token=token,
service="swift",
)
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))
_append_synthetic_heads(
report, packs, host=host, token=token, project_id=project_id, ctx=base_ctx
)
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)
if not collections_only:
_append_synthetic_heads(
report, packs, host=host, token=token, project_id=project_id, ctx=ctx
)
else:
# Smoke: HEAD only for the collection GET paths we probed.
for pack in packs.values():
for op in pack.operations:
if op.method != "GET" or "{" in op.path:
continue
result, _ = probe_operation(
host,
pack,
op,
token=token,
ctx=ctx,
project_id=project_id,
mode="probe",
method_override="HEAD",
)
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)