535 lines
18 KiB
Python
535 lines
18 KiB
Python
"""Full REST verb×path×majors matrix for pulumi-tests (Layer A HTTP contract).
|
||
|
||
Probes ``methods_for_major(M)`` for majors 6–9 with session auth.
|
||
Verbs: whatever is in IMPLEMENTED (GET/PUT/PATCH/POST/DELETE) plus a synthetic
|
||
HEAD for every GET path. Pass requires ``critical == 0`` and
|
||
``probed == declared`` per major and in aggregate.
|
||
|
||
100% coverage here means the HTTP contract matrix — not pulumi-vsphere resource
|
||
count (Layer B).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import ssl
|
||
import urllib.error
|
||
import urllib.request
|
||
from base64 import b64encode
|
||
from collections import Counter
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
|
||
from app.vsphere.contracts.matrix import VERSIONS, methods_for_major
|
||
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED
|
||
|
||
_PATH_SUBS = {
|
||
"{vm}": "vm-101",
|
||
"{host}": "host-11",
|
||
"{datastore}": "datastore-31",
|
||
"{task}": "task-1",
|
||
"{snapshot}": "snapshot-missing",
|
||
"{category_id}": "cat-lab-1",
|
||
"{tag_id}": "tag-lab-1",
|
||
"{item_id}": "item-ubuntu",
|
||
"{library_item_id}": "item-ubuntu",
|
||
"{library_id}": "lib-local-1",
|
||
"{folder}": "group-v23",
|
||
"{datacenter}": "datacenter-21",
|
||
"{cluster}": "domain-c21",
|
||
"{resource_pool}": "resgroup-22",
|
||
"{permission_id}": "999999",
|
||
"{policy}": "policy-default",
|
||
"{disk}": "2000",
|
||
"{nic}": "4000",
|
||
"{cdrom}": "3000",
|
||
"{floppy}": "8000",
|
||
"{port}": "9000",
|
||
"{adapter}": "1000",
|
||
"{provider}": "vsphere.local",
|
||
"{supervisor}": "supervisor-1",
|
||
"{namespace}": "ns-lab-1",
|
||
"{role}": "ReadOnly",
|
||
"{zone}": "zone-1",
|
||
"{project}": "project-1",
|
||
"{domain}": "lab.local",
|
||
"{service}": "vsphere-ui",
|
||
"{depot}": "depot-1",
|
||
"{component}": "component-1",
|
||
"{image}": "image-1",
|
||
"{draft}": "draft-1",
|
||
"{connection}": "connection-1",
|
||
"{vpc}": "vpc-1",
|
||
"{subnet}": "subnet-1",
|
||
"{session_id}": "session-lab-1",
|
||
"{download_session_id}": "session-lab-1",
|
||
"{update_session_id}": "session-lab-1",
|
||
"{subscription_id}": "sub-1",
|
||
"{usage_id}": "usage-1",
|
||
"{version}": "1",
|
||
"{chain}": "chain-1",
|
||
"{node}": "node-1",
|
||
"{profile}": "profile-1",
|
||
"{interface}": "nic0",
|
||
"{core}": "core-1",
|
||
"{network}": "network-41",
|
||
"{commit}": "commit-lab-1",
|
||
}
|
||
|
||
_ACCEPT_CLIENT = {400, 401, 403, 404, 405, 409, 412, 422}
|
||
|
||
_SKIP_DELETE = frozenset(
|
||
{
|
||
("DELETE", "/api/session"),
|
||
("DELETE", "/rest/com/vmware/cis/session"),
|
||
}
|
||
)
|
||
|
||
# Collection / entity GETs that must be non-empty after small seed (major 9).
|
||
_INVENTORY_CRITICAL = {
|
||
"/api/vcenter/vm",
|
||
"/api/vcenter/host",
|
||
"/api/vcenter/datastore",
|
||
"/api/vcenter/network",
|
||
"/api/vcenter/cluster",
|
||
"/api/vcenter/datacenter",
|
||
"/api/vcenter/folder",
|
||
"/api/vcenter/resource-pool",
|
||
"/api/cis/tagging/category",
|
||
"/api/cis/tagging/tag",
|
||
"/api/content/library",
|
||
"/api/content/local-library",
|
||
"/api/vcenter/network/dvs",
|
||
"/api/vcenter/storage/policies",
|
||
"/api/vcenter/privilege",
|
||
"/api/vcenter/authorization/roles",
|
||
"/api/vcenter/authorization/permissions",
|
||
"/api/cis/tasks",
|
||
"/api/esx/settings/clusters/{cluster}/software",
|
||
"/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
|
||
"/api/appliance/access/ssh",
|
||
"/api/appliance/services",
|
||
}
|
||
|
||
# GET paths that may legally return empty/null bodies.
|
||
_EMPTY_OK_GET = frozenset(
|
||
{
|
||
"/api/session",
|
||
"/rest/com/vmware/cis/session",
|
||
}
|
||
)
|
||
|
||
|
||
def _base() -> str:
|
||
explicit = os.environ.get("VSPHERE_BASE")
|
||
if explicit:
|
||
return explicit.rstrip("/")
|
||
server = os.environ.get("VSPHERE_SERVER", "localhost")
|
||
if server.startswith("http://") or server.startswith("https://"):
|
||
return server.rstrip("/")
|
||
return f"https://{server}"
|
||
|
||
|
||
def _creds() -> tuple[str, str]:
|
||
return (
|
||
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
|
||
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
|
||
)
|
||
|
||
|
||
def _ctx() -> ssl.SSLContext | None:
|
||
if not _base().startswith("https://"):
|
||
return None
|
||
return ssl._create_unverified_context() # noqa: S323
|
||
|
||
|
||
def concrete_path(path: str) -> str:
|
||
out = path
|
||
for key, value in _PATH_SUBS.items():
|
||
out = out.replace(key, value)
|
||
return re.sub(r"\{([A-Za-z0-9_]+)\}", r"probe-\1", out)
|
||
|
||
|
||
def request(
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
headers: dict[str, str],
|
||
data: bytes | None = None,
|
||
) -> tuple[int, str]:
|
||
if "?" in path:
|
||
base_path, query = path.split("?", 1)
|
||
url = f"{_base()}{concrete_path(base_path)}?{query}"
|
||
else:
|
||
url = f"{_base()}{concrete_path(path)}"
|
||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||
try:
|
||
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
|
||
body = resp.read().decode("utf-8", errors="replace")
|
||
return int(resp.status), body
|
||
except urllib.error.HTTPError as error:
|
||
body = error.read().decode("utf-8", errors="replace")
|
||
return int(error.code), body
|
||
|
||
|
||
def login() -> str:
|
||
user, password = _creds()
|
||
basic = b64encode(f"{user}:{password}".encode()).decode()
|
||
code, body = request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
|
||
if code not in {200, 201}:
|
||
raise RuntimeError(f"session failed: {code} {body[:200]}")
|
||
return json.loads(body)
|
||
|
||
|
||
def declared_routes(major: int) -> list[tuple[str, str]]:
|
||
"""Registry routes for a major, plus synthetic HEAD for each GET."""
|
||
|
||
methods = methods_for_major(major)
|
||
base = [(verb, path) for (verb, path) in sorted(methods) if (verb, path) not in _SKIP_DELETE]
|
||
heads = [("HEAD", path) for verb, path in base if verb == "GET"]
|
||
# Keep HEAD adjacent after its GET in verb order during probe via sort key.
|
||
return base + heads
|
||
|
||
|
||
def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
|
||
if verb not in {"POST", "PUT", "PATCH"}:
|
||
return path, None
|
||
|
||
if path.endswith("/power") and verb == "POST":
|
||
if "/guest/power" in path:
|
||
return f"{path}?action=reboot", b"{}"
|
||
return f"{path}?action=start", b"{}"
|
||
|
||
if path.endswith("/maintenance") and verb == "POST":
|
||
return f"{path}?action=enter", b"{}"
|
||
|
||
if path.endswith("/folder/{folder}") and verb == "POST":
|
||
# Do not rename seed folder MOIDs (breaks /Datacenter/vm/... inventory paths).
|
||
return (
|
||
"/api/vcenter/folder/folder-missing-matrix?action=rename",
|
||
json.dumps({"name": "folder-renamed-probe"}).encode(),
|
||
)
|
||
|
||
if path == "/api/cis/tasks" and verb == "POST":
|
||
return f"{path}?action=list", json.dumps({"filter_spec": {}}).encode()
|
||
|
||
suffix = secrets.token_hex(4)
|
||
bodies: dict[str, dict[str, Any]] = {
|
||
"/api/vcenter/vm": {
|
||
"name": f"matrix-probe-vm-{suffix}",
|
||
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
|
||
"cpu_count": 1,
|
||
"memory_size_MiB": 512,
|
||
},
|
||
# Missing parent → 404 (client_4xx). Avoid creating extra Datacenter/Cluster
|
||
# trees that leave a second ResourcePool named "Resources" for govmomi.
|
||
"/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}", "folder": "folder-missing-matrix"},
|
||
"/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}", "folder": "folder-missing-matrix"},
|
||
"/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"},
|
||
"/api/vcenter/resource-pool": {
|
||
"name": f"probe-rp-{suffix}",
|
||
"parent": "resgroup-missing-matrix",
|
||
},
|
||
"/api/vcenter/network/dvs": {"name": f"probe-dvs-{suffix}"},
|
||
"/api/vcenter/network/dvpg": {
|
||
"name": f"probe-dvpg-{suffix}",
|
||
"dvs": "dvs-51",
|
||
"vlan_id": 10,
|
||
},
|
||
"/api/cis/tagging/category": {
|
||
"create_spec": {
|
||
"name": f"probe-cat-{suffix}",
|
||
"description": "probe",
|
||
"cardinality": "MULTIPLE",
|
||
"associable_types": [],
|
||
}
|
||
},
|
||
"/api/cis/tagging/tag": {
|
||
"create_spec": {
|
||
"name": f"probe-tag-{suffix}",
|
||
"category_id": "missing-category",
|
||
"description": "x",
|
||
}
|
||
},
|
||
"/api/cis/tagging/tag-association": {
|
||
"action": "list-attached-tags",
|
||
"tag_id": "x",
|
||
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
|
||
},
|
||
"/api/content/local-library": {"create_spec": {"name": f"probe-lib-{suffix}"}},
|
||
"/api/content/library/item": {
|
||
"create_spec": {
|
||
"library_id": "lib-missing",
|
||
"name": f"probe-item-{suffix}",
|
||
"type": "ovf",
|
||
}
|
||
},
|
||
"/api/vcenter/ovf/library-item/{item_id}": {
|
||
"deployment_spec": {"name": f"ovf-probe-{suffix}"},
|
||
"target": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
|
||
},
|
||
"/api/vcenter/authorization/permissions": {
|
||
"principal": "readonly@vsphere.local",
|
||
"role": "ReadOnly",
|
||
"entity": "datacenter-21",
|
||
},
|
||
"/api/vcenter/datastore/{datastore}/files": {
|
||
"path": f"/probe-{suffix}.txt",
|
||
"size": 1,
|
||
"type": "FILE",
|
||
},
|
||
"/api/vcenter/vm/{vm}/hardware/cpu": {"count": 2},
|
||
"/api/vcenter/vm/{vm}/hardware/memory": {"size_MiB": 1024},
|
||
"/api/vcenter/vm/{vm}/hardware/disk": {"type": "SCSI", "new_vmdk": {"capacity": 1024}},
|
||
"/api/vcenter/vm/{vm}/hardware/ethernet": {
|
||
"type": "VMXNET3",
|
||
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
|
||
},
|
||
"/api/vcenter/vm/{vm}/snapshots": {"name": f"probe-snap-{suffix}"},
|
||
"/api/vcenter/vm/{vm}/snapshots/{snapshot}": {"action": "revert"},
|
||
"/api/vcenter/vm/{vm}/clone": {
|
||
"name": f"probe-clone-{suffix}",
|
||
"placement": {"folder": "group-v23", "host": "host-11"},
|
||
},
|
||
"/api/vcenter/vm/{vm}/relocate": {"placement": {"host": "host-12"}},
|
||
"/api/vcenter/vm/{vm}/tools": {"action": "upgrade"},
|
||
"/api/vcenter/vm/{vm}/console/tickets": {"type": "WEBMKS"},
|
||
"/api/vcenter/vm/{vm}/guest/customization": {"name": {"name": f"guest-probe-{suffix}"}},
|
||
"/api/vcenter/vm/{vm}": {"action": "unregister"},
|
||
}
|
||
body = bodies.get(path, {})
|
||
return path, json.dumps(body).encode()
|
||
|
||
|
||
def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
|
||
params = urlencode({"major": major})
|
||
code, body = request(
|
||
"POST",
|
||
f"/ui/api/contract/apply?{params}",
|
||
headers=headers,
|
||
)
|
||
if code >= 400:
|
||
raise RuntimeError(f"contract apply major={major} failed: {code} {body[:200]}")
|
||
return json.loads(body)
|
||
|
||
|
||
def _classify(verb: str, path: str) -> str:
|
||
lookup = verb if verb != "HEAD" else "GET"
|
||
if (lookup, path) in CORE_IMPLEMENTED:
|
||
return "deep"
|
||
status = IMPLEMENTED.get((lookup, path))
|
||
if status == "stub":
|
||
return "stub"
|
||
return "deep" if status == "implemented" else "unknown"
|
||
|
||
|
||
def _is_empty_payload(body: str) -> bool:
|
||
if not body or not body.strip():
|
||
return True
|
||
try:
|
||
parsed = json.loads(body)
|
||
except json.JSONDecodeError:
|
||
return False
|
||
return parsed in ([], {}, None, "")
|
||
|
||
|
||
def _record_result(
|
||
*,
|
||
major: int,
|
||
verb: str,
|
||
path: str,
|
||
code: int,
|
||
body: str,
|
||
buckets: Counter[str],
|
||
failures: list[dict[str, Any]],
|
||
deep_stub: Counter[str],
|
||
) -> None:
|
||
kind = _classify(verb, path)
|
||
deep_stub[kind] += 1
|
||
|
||
def _fail(expected: str, *, bucket: str) -> None:
|
||
buckets[bucket] += 1
|
||
failures.append(
|
||
{
|
||
"major": major,
|
||
"verb": verb,
|
||
"path": path,
|
||
"kind": kind,
|
||
"status": code,
|
||
"critical": True,
|
||
"body": body[:200],
|
||
"expected": expected,
|
||
}
|
||
)
|
||
|
||
if 200 <= code < 300:
|
||
buckets["success_2xx"] += 1
|
||
if kind == "stub":
|
||
buckets["stub_ok"] += 1
|
||
# HEAD bodies are always empty by design.
|
||
if verb == "HEAD":
|
||
return
|
||
if major == 9 and verb == "GET" and path not in _EMPTY_OK_GET:
|
||
if '"stub": true' in body or '"stub":true' in body:
|
||
if path in _INVENTORY_CRITICAL or kind == "deep":
|
||
_fail("non-stub JSON", bucket="stub_marker")
|
||
elif path in _INVENTORY_CRITICAL and _is_empty_payload(body):
|
||
_fail("non-empty seeded data", bucket="empty_inventory")
|
||
return
|
||
|
||
# Synthetic HEAD must be served (middleware); 405 is critical for HEAD only.
|
||
if verb == "HEAD" and code == 405:
|
||
_fail("HEAD supported via GET route", bucket="head_405")
|
||
return
|
||
|
||
if code in _ACCEPT_CLIENT:
|
||
buckets["client_4xx"] += 1
|
||
return
|
||
|
||
if code == 501:
|
||
_fail("no 501 on registered route", bucket="unexpected_501")
|
||
return
|
||
|
||
if code >= 500:
|
||
_fail("no 5xx", bucket="server_5xx")
|
||
return
|
||
|
||
_fail(f"unexpected status {code}", bucket=f"other_{code}")
|
||
|
||
|
||
def probe_major(major: int, session: str) -> dict[str, Any]:
|
||
headers = {
|
||
"vmware-api-session-id": session,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
}
|
||
applied = apply_major(major, headers)
|
||
routes = declared_routes(major)
|
||
declared = len(routes)
|
||
|
||
verb_order = {"GET": 0, "HEAD": 1, "PUT": 2, "PATCH": 3, "POST": 4, "DELETE": 5}
|
||
routes = sorted(routes, key=lambda item: (verb_order.get(item[0], 9), item[1]))
|
||
|
||
buckets: Counter[str] = Counter()
|
||
deep_stub: Counter[str] = Counter()
|
||
failures: list[dict[str, Any]] = []
|
||
probed = 0
|
||
by_verb: Counter[str] = Counter()
|
||
|
||
for verb, path in routes:
|
||
by_verb[verb] += 1
|
||
if verb == "DELETE" and path in {
|
||
"/api/vcenter/datacenter/{datacenter}",
|
||
"/api/vcenter/cluster/{cluster}",
|
||
"/api/vcenter/folder/{folder}",
|
||
"/api/vcenter/resource-pool/{resource_pool}",
|
||
"/api/vcenter/vm/{vm}",
|
||
"/api/content/local-library/{library_id}",
|
||
"/api/content/library/item/{library_item_id}",
|
||
}:
|
||
# Avoid destroying seed MOIDs — probe missing ids (expect 404).
|
||
url_path = path
|
||
for token, missing in (
|
||
("{vm}", "vm-missing-matrix"),
|
||
("{datacenter}", "dc-missing"),
|
||
("{cluster}", "cluster-missing"),
|
||
("{folder}", "folder-missing"),
|
||
("{resource_pool}", "rp-missing"),
|
||
("{library_id}", "lib-missing-matrix"),
|
||
("{library_item_id}", "item-missing-matrix"),
|
||
):
|
||
url_path = url_path.replace(token, missing)
|
||
code, body = request(verb, url_path, headers=headers)
|
||
elif verb == "HEAD":
|
||
url_path = path
|
||
if path == "/api/content/library/item":
|
||
url_path = f"{path}?library_id=lib-local-1"
|
||
code, body = request("HEAD", url_path, headers=headers)
|
||
else:
|
||
url_path, data = _payload_for(verb, path)
|
||
if verb == "GET" and path == "/api/content/library/item":
|
||
url_path = f"{url_path}?library_id=lib-local-1"
|
||
code, body = request(verb, url_path, headers=headers, data=data)
|
||
|
||
probed += 1
|
||
_record_result(
|
||
major=major,
|
||
verb=verb,
|
||
path=path,
|
||
code=code,
|
||
body=body,
|
||
buckets=buckets,
|
||
failures=failures,
|
||
deep_stub=deep_stub,
|
||
)
|
||
|
||
critical = sum(1 for f in failures if f.get("critical"))
|
||
coverage_ok = probed == declared and critical == 0
|
||
return {
|
||
"major": major,
|
||
"version": applied.get("runtime_version"),
|
||
"declared": declared,
|
||
"method_count": len(methods_for_major(major)),
|
||
"by_verb": dict(by_verb),
|
||
"probed": probed,
|
||
"probed_eq_declared": probed == declared,
|
||
"buckets": dict(buckets),
|
||
"deep_vs_stub": dict(deep_stub),
|
||
"failures": failures,
|
||
"failed": len(failures),
|
||
"critical": critical,
|
||
"coverage_line": f"{probed - critical}/{declared}",
|
||
"ok": coverage_ok,
|
||
}
|
||
|
||
|
||
def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
|
||
"""Probe IMPLEMENTED × majors (+ HEAD). Returns summary for suite JSON/HTML."""
|
||
|
||
if majors is None:
|
||
majors = [6, 7, 8, 9]
|
||
for major in majors:
|
||
if major not in VERSIONS:
|
||
raise ValueError(f"unknown major {major}")
|
||
|
||
session = login()
|
||
reports: list[dict[str, Any]] = []
|
||
all_failures: list[dict[str, Any]] = []
|
||
verb_totals: Counter[str] = Counter()
|
||
declared_total = 0
|
||
probed_total = 0
|
||
critical_total = 0
|
||
|
||
for major in majors:
|
||
report = probe_major(major, session)
|
||
reports.append(report)
|
||
all_failures.extend(report["failures"])
|
||
declared_total += int(report["declared"])
|
||
probed_total += int(report["probed"])
|
||
critical_total += int(report["critical"])
|
||
for verb, count in report["by_verb"].items():
|
||
verb_totals[verb] += count
|
||
session = login()
|
||
|
||
apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"})
|
||
|
||
coverage_line = f"{probed_total - critical_total}/{declared_total}"
|
||
ok = critical_total == 0 and probed_total == declared_total and all(r["ok"] for r in reports)
|
||
return {
|
||
"base": _base(),
|
||
"majors": reports,
|
||
"by_verb": dict(verb_totals),
|
||
"declared": declared_total,
|
||
"probed": probed_total,
|
||
"total": probed_total,
|
||
"critical": critical_total,
|
||
"failed": critical_total,
|
||
"failures": all_failures[:120],
|
||
"coverage_line": coverage_line,
|
||
"probed_eq_declared": probed_total == declared_total,
|
||
"ok": ok,
|
||
}
|