147f04c9a2
Persist aggregates and server security groups in domain tables, add Cinder quota show from DB-backed defaults, align unit tests with OpenStack series, auto-pick Keystone :5000/:15000 for smoke, and document test locations plus 6514/6514 results.
1046 lines
39 KiB
Python
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)
|