Files
vmware-api-simulator/scripts/vsphere_seed_dump_audit.py
T

326 lines
13 KiB
Python

#!/usr/bin/env python3
"""Audit seeded inventory against live Automation API responses.
Compares the declarative profile (small / large / big) to live
GET /api/vcenter/* dumps: counts, MOID/name/power for every VM and host,
per-host placement via filter, and a canonical AND-filter that must hit on
all profiles (web-01 @ host-11, POWERED_ON).
Run against a freshly seeded lab (``make seed``) before matrix probes mutate
inventory. Exit 0 only on a 100% dump match.
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from base64 import b64encode
from collections import defaultdict
from typing import Any
from urllib.parse import urlencode
from app.vsphere.profiles import build_vsphere_profile
BASE = os.getenv("VSPHERE_BASE", "https://localhost")
USER = os.getenv("VSPHERE_USER", "administrator@vsphere.local")
PASSWORD = os.getenv("VSPHERE_PASSWORD", "VMware1!")
# Works on small (3h), large (10h), and big (20h).
CANONICAL_FILTER = {
"names": "web-01",
"hosts": "host-11",
"power_states": "POWERED_ON",
}
def _ctx() -> ssl.SSLContext | None:
if not BASE.startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def _request(method: str, path: str, *, headers: dict[str, str]) -> tuple[int, Any]:
req = urllib.request.Request(f"{BASE}{path}", method=method, headers=headers)
try:
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
raw = resp.read().decode("utf-8", errors="replace")
return int(resp.status), json.loads(raw) if raw.strip() else None
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8", errors="replace")
try:
body = json.loads(raw) if raw.strip() else raw
except json.JSONDecodeError:
body = raw
return int(error.code), body
def _session() -> str:
basic = b64encode(f"{USER}:{PASSWORD}".encode()).decode()
code, body = _request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
if code not in {200, 201} or not isinstance(body, str):
raise SystemExit(json.dumps({"error": "session failed", "status": code, "body": body}))
return body
def _by_type(profile_objects: tuple[Any, ...]) -> dict[str, list[Any]]:
out: dict[str, list[Any]] = {}
for obj in profile_objects:
out.setdefault(obj.type, []).append(obj)
return out
def audit_profile(profile_name: str, *, hosts: int | None, vms: int | None) -> dict[str, Any]:
profile = build_vsphere_profile(profile_name, large_hosts=hosts, large_vms=vms)
expected = _by_type(profile.objects)
session = _session()
headers = {"vmware-api-session-id": session, "Accept": "application/json"}
failures: list[dict[str, Any]] = []
live_vms_code, live_vms = _request("GET", "/api/vcenter/vm", headers=headers)
live_hosts_code, live_hosts = _request("GET", "/api/vcenter/host", headers=headers)
live_ds_code, live_ds = _request("GET", "/api/vcenter/datastore", headers=headers)
live_net_code, live_net = _request("GET", "/api/vcenter/network", headers=headers)
live_cl_code, live_cl = _request("GET", "/api/vcenter/cluster", headers=headers)
live_dc_code, live_dc = _request("GET", "/api/vcenter/datacenter", headers=headers)
live_folder_code, live_folder = _request("GET", "/api/vcenter/folder", headers=headers)
live_rp_code, live_rp = _request("GET", "/api/vcenter/resource-pool", headers=headers)
for label, code, payload in (
("vm", live_vms_code, live_vms),
("host", live_hosts_code, live_hosts),
("datastore", live_ds_code, live_ds),
("network", live_net_code, live_net),
("cluster", live_cl_code, live_cl),
("datacenter", live_dc_code, live_dc),
("folder", live_folder_code, live_folder),
("resource-pool", live_rp_code, live_rp),
):
if code != 200 or not isinstance(payload, list):
failures.append({"check": f"list/{label}", "status": code, "body": str(payload)[:160]})
exp_vms = expected.get("VirtualMachine", [])
exp_hosts = expected.get("HostSystem", [])
exp_ds = expected.get("Datastore", [])
exp_nets = [
*expected.get("Network", []),
*expected.get("DistributedVirtualPortgroup", []),
]
exp_clusters = expected.get("ClusterComputeResource", [])
exp_dcs = expected.get("Datacenter", [])
exp_folders = expected.get("Folder", [])
exp_rps = expected.get("ResourcePool", [])
def _count(label: str, got: Any, want: int) -> None:
if not isinstance(got, list):
return
if len(got) != want:
failures.append({"check": f"count/{label}", "expected": want, "actual": len(got)})
_count("vm", live_vms, len(exp_vms))
_count("host", live_hosts, len(exp_hosts))
_count("datastore", live_ds, len(exp_ds))
_count("network", live_net, len(exp_nets))
_count("cluster", live_cl, len(exp_clusters))
_count("datacenter", live_dc, len(exp_dcs))
_count("folder", live_folder, len(exp_folders))
_count("resource-pool", live_rp, len(exp_rps))
if isinstance(live_hosts, list):
live_host_map = {row.get("host"): row for row in live_hosts if isinstance(row, dict)}
for obj in exp_hosts:
row = live_host_map.get(obj.moid)
if row is None:
failures.append({"check": "host/missing", "host": obj.moid})
continue
if row.get("name") != obj.name:
failures.append(
{
"check": "host/name",
"host": obj.moid,
"expected": obj.name,
"actual": row.get("name"),
}
)
if isinstance(live_vms, list):
live_vm_map = {row.get("vm"): row for row in live_vms if isinstance(row, dict)}
for obj in exp_vms:
row = live_vm_map.get(obj.moid)
if row is None:
failures.append({"check": "vm/missing", "vm": obj.moid, "name": obj.name})
continue
want = {
"name": obj.name,
"power_state": obj.props.get("power_state"),
"cpu_count": obj.props.get("cpu_count"),
"memory_size_MiB": obj.props.get("memory_size_mib"),
}
for key, expected_value in want.items():
if row.get(key) != expected_value:
failures.append(
{
"check": f"vm/{key}",
"vm": obj.moid,
"expected": expected_value,
"actual": row.get(key),
}
)
# Per-host placement dump via filter (O(hosts), not O(vms)).
by_host: dict[str, set[str]] = defaultdict(set)
for obj in exp_vms:
by_host[str(obj.props.get("host"))].add(obj.moid)
for host_moid, want_ids in sorted(by_host.items()):
qs = urlencode({"hosts": host_moid})
code, filtered = _request("GET", f"/api/vcenter/vm?{qs}", headers=headers)
if code != 200 or not isinstance(filtered, list):
failures.append({"check": "host-filter", "host": host_moid, "status": code})
continue
got_ids = {row.get("vm") for row in filtered if isinstance(row, dict)}
missing = sorted(want_ids - got_ids)
extra = sorted(got_ids - want_ids)
if missing or extra:
failures.append(
{
"check": "host-filter/mismatch",
"host": host_moid,
"missing": missing[:20],
"extra": extra[:20],
"expected": len(want_ids),
"actual": len(got_ids),
}
)
qs = urlencode(CANONICAL_FILTER)
code, filtered = _request("GET", f"/api/vcenter/vm?{qs}", headers=headers)
if code != 200 or not isinstance(filtered, list) or len(filtered) != 1:
failures.append(
{
"check": "canonical-filter",
"query": CANONICAL_FILTER,
"status": code,
"hits": filtered if not isinstance(filtered, list) else len(filtered),
"body": filtered[:3] if isinstance(filtered, list) else filtered,
}
)
elif filtered[0].get("name") != "web-01" or filtered[0].get("vm") != "vm-101":
failures.append(
{
"check": "canonical-filter/identity",
"expected": {"vm": "vm-101", "name": "web-01"},
"actual": filtered[0],
}
)
detail_code, detail = _request("GET", "/api/vcenter/vm/vm-101", headers=headers)
if detail_code != 200 or not isinstance(detail, dict) or detail.get("name") != "web-01":
failures.append({"check": "vm/detail", "status": detail_code, "body": str(detail)[:200]})
# --- proportional platform extras ---
extras_scale = int(getattr(profile, "extras_scale", 1) or 1)
want_libraries = 2 + max(0, extras_scale - 1) # local+published + scaled locals
want_categories = 3 + max(0, extras_scale - 1) # Environment/Owner/Lab + scaled
want_folders = 8 + max(0, (extras_scale - 1) * 2)
lib_code, libraries = _request("GET", "/api/content/library", headers=headers)
if lib_code != 200 or not isinstance(libraries, list):
failures.append({"check": "extras/libraries", "status": lib_code})
elif len(libraries) < want_libraries:
failures.append(
{
"check": "extras/libraries/count",
"expected_min": want_libraries,
"actual": len(libraries),
"extras_scale": extras_scale,
}
)
cat_code, categories = _request("GET", "/api/cis/tagging/category", headers=headers)
if cat_code != 200 or not isinstance(categories, list):
failures.append({"check": "extras/categories", "status": cat_code})
elif len(categories) < want_categories:
failures.append(
{
"check": "extras/categories/count",
"expected_min": want_categories,
"actual": len(categories),
"extras_scale": extras_scale,
}
)
if isinstance(live_folder, list) and len(live_folder) != want_folders:
failures.append(
{
"check": "count/folder-scaled",
"expected": want_folders,
"actual": len(live_folder),
"extras_scale": extras_scale,
}
)
# Datastore references on VMs must exist in inventory.
ds_ids = {obj.moid for obj in exp_ds}
for obj in exp_vms:
ds = obj.props.get("datastore")
if ds and ds not in ds_ids:
failures.append({"check": "vm/datastore-missing", "vm": obj.moid, "datastore": ds})
break
return {
"base": BASE,
"profile": profile.name,
"extras_scale": extras_scale,
"expected": {
"hosts": len(exp_hosts),
"vms": len(exp_vms),
"datastores": len(exp_ds),
"networks": len(exp_nets),
"clusters": len(exp_clusters),
"datacenters": len(exp_dcs),
"folders": want_folders,
"resource_pools": len(exp_rps),
"libraries_min": want_libraries,
"categories_min": want_categories,
},
"live": {
"hosts": len(live_hosts) if isinstance(live_hosts, list) else None,
"vms": len(live_vms) if isinstance(live_vms, list) else None,
"datastores": len(live_ds) if isinstance(live_ds, list) else None,
"networks": len(live_net) if isinstance(live_net, list) else None,
"clusters": len(live_cl) if isinstance(live_cl, list) else None,
"datacenters": len(live_dc) if isinstance(live_dc, list) else None,
"folders": len(live_folder) if isinstance(live_folder, list) else None,
"resource_pools": len(live_rp) if isinstance(live_rp, list) else None,
"libraries": len(libraries) if isinstance(libraries, list) else None,
"categories": len(categories) if isinstance(categories, list) else None,
},
"failure_count": len(failures),
"failures": failures[:100],
"ok": len(failures) == 0,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--profile",
default=os.getenv("SEED_VSPHERE_PROFILE", "large"),
help="small | large | big (demo-cluster aliases big)",
)
parser.add_argument("--hosts", type=int, default=None)
parser.add_argument("--vms", type=int, default=None)
args = parser.parse_args()
report = audit_profile(args.profile, hosts=args.hosts, vms=args.vms)
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())