Files
openstack-api-simulator/app/openstack/surface_probe.py
T
inecs 6033967e6a 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.
2026-07-18 04:26:48 +03:00

876 lines
32 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 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)