Add OpenStack request-body schemas and nested console PARAM sync.

This commit is contained in:
2026-07-18 08:47:16 +03:00
parent 6033967e6a
commit 2a8ce3a287
101 changed files with 150428 additions and 612 deletions
+198 -28
View File
@@ -14,10 +14,13 @@ 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"\{([^{}]+)\}")
@@ -26,6 +29,59 @@ ACCEPTABLE = frozenset({200, 201, 202, 204, 300, 400, 401, 403, 404, 405, 409, 4
# 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:
@@ -123,16 +179,6 @@ def fill_path(template: str, ctx: dict[str, str] | None = None) -> str:
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,
*,
@@ -259,27 +305,31 @@ def http_request(
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()
headers = {"Accept": "application/json"}
hdrs = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
hdrs["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
hdrs["X-Auth-Token"] = token
if service:
headers["X-OpenStack-Route-Service"] = service
req = urllib.request.Request(url, data=body, headers=headers, method=method)
# 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()
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()
raw = exc.read().decode() if method.upper() != "HEAD" else ""
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
@@ -434,6 +484,7 @@ def probe_operation(
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:
@@ -442,27 +493,72 @@ def probe_operation(
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)
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 = ""
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
# 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=op.method,
method=method,
path=op.path,
operation_id=op.operation_id,
operation_id=op.operation_id if method != "HEAD" else f"{op.operation_id}__head",
status=status,
detail=detail,
mode=mode,
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,
@@ -490,11 +586,33 @@ def _seed_context(
("ironic", "/v1/nodes", "nodes", "node"),
("ironic", "/v1/drivers", "drivers", "driver"),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", "loadbalancer"),
("swift", f"/v1/{project_id}", None, "account"),
("swift", f"/v1/AUTH_{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):
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:
@@ -527,7 +645,7 @@ def _ensure_swift_resources(host: str, token: str, project_id: str, ctx: dict[st
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("/")
base = service_base_url(host, "swift")
st, _ = http_request(
"PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={}
)
@@ -766,6 +884,34 @@ def probe_series_lifecycle(
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"
)
@@ -819,6 +965,9 @@ def probe_series_lifecycle(
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
@@ -852,6 +1001,27 @@ def probe_series(
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