"""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:`` 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)