Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.

This commit is contained in:
2026-07-18 08:46:39 +03:00
parent f8d3cbdd59
commit 63cc409424
71 changed files with 38380 additions and 796 deletions
+1
View File
@@ -70,6 +70,7 @@ hypothesis/
# Docker / local runtime
*.log
docker-compose.override.yml
.cache/
# Terraform local state (never commit)
*.tfstate
+62 -3
View File
@@ -13,8 +13,15 @@ PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/vmware-api-simulator
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up down restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell release release-build release-up release-down release-seed helm-deps helm-template \
pulumi-tests pulumi-tests-smoke test-pulumi-smoke test-pulumi test-smoke-all test-all clean-test-resources
# Preferred host ports for `make up-local` (gitignored docker-compose.override.yml).
# Override on the CLI if needed: make up-local LOCAL_HTTPS_PORT=9443
COMPOSE_OVERRIDE ?= docker-compose.override.yml
LOCAL_HTTP_PORT ?= 18080
LOCAL_HTTPS_PORT ?= 18443
LOCAL_POSTGRES_PORT ?= 15434
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up up-local down down-local restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell push release release-build release-up release-down release-seed helm-deps helm-template \
pulumi-tests pulumi-tests-smoke test-pulumi-smoke test-pulumi test-smoke-all test-all clean-test-resources vsphere-universe vsphere-param-index vsphere-bundles vsphere-seed-dump
help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -70,9 +77,26 @@ vsphere-matrix: ## Full REST matrix: all verbs × majors 69 (no 5xx)
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_full_matrix_probe.py
vsphere-seed-dump: ## 100% seed↔live inventory dump audit (PROFILE=small|large|big)
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --wait
SEED_VSPHERE_PROFILE="$${VSPHERE_PROFILE:-$${PROFILE:-large}}" \
SEED_VSPHERE_LARGE_VMS="$${VSPHERE_VMS:-1000}" \
SEED_VSPHERE_LARGE_HOSTS="$${VSPHERE_HOSTS:-10}" \
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
$(COMPOSE) --profile tools run --rm --no-deps \
-e VSPHERE_BASE=http://simulator:8080 \
-e SEED_VSPHERE_PROFILE="$${VSPHERE_PROFILE:-$${PROFILE:-large}}" \
-e SEED_VSPHERE_LARGE_VMS="$${VSPHERE_VMS:-1000}" \
-e SEED_VSPHERE_LARGE_HOSTS="$${VSPHERE_HOSTS:-10}" \
$(SERVICE_DEV) python scripts/vsphere_seed_dump_audit.py \
--profile "$${VSPHERE_PROFILE:-$${PROFILE:-large}}"
vsphere-universe: ## Regenerate Broadcom Automation API universe.json from operations index
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/generate_vsphere_universe.py
vsphere-param-index: ## Build console param index from official Automation OpenAPI
python3 scripts/generate_vsphere_param_index.py
vsphere-bundles: ## Regenerate stub OpenAPI matrices + evidence ledgers
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/write_vsphere_bundles.py
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/write_vsphere_evidence.py
@@ -94,11 +118,17 @@ test-vsphere: ## Native vSphere unit + integration + surface + majors matrix
tests/integration/test_vsphere_api_surface_data.py \
tests/integration/test_vsphere_soap_depth.py \
tests/integration/test_vsphere_soap_create_vm.py \
tests/integration/test_vsphere_deep_realism.py \
tests/integration/test_vsphere_full_api.py -q
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_surface_probe.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_full_matrix_probe.py
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
$(COMPOSE) --profile tools run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 \
-e SEED_VSPHERE_PROFILE="$${VSPHERE_PROFILE:-$${PROFILE:-large}}" \
$(SERVICE_DEV) python scripts/vsphere_seed_dump_audit.py \
--profile "$${VSPHERE_PROFILE:-$${PROFILE:-large}}"
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_real_data_spotcheck.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
@@ -137,9 +167,21 @@ up: ## Start PostgreSQL, simulator, and TLS gateway
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait
up-local: ## Start on free local ports via gitignored compose override
@COMPOSE="$(COMPOSE)" COMPOSE_OVERRIDE="$(COMPOSE_OVERRIDE)" \
LOCAL_HTTP_PORT="$(LOCAL_HTTP_PORT)" \
LOCAL_HTTPS_PORT="$(LOCAL_HTTPS_PORT)" \
LOCAL_POSTGRES_PORT="$(LOCAL_POSTGRES_PORT)" \
bash scripts/up_local.sh
down: ## Stop local services
$(COMPOSE) down
down-local: ## Stop local stack and remove gitignored compose override
$(COMPOSE) down
@rm -f $(COMPOSE_OVERRIDE)
@echo "Stopped local stack and removed $(COMPOSE_OVERRIDE)"
restart: ## Rebuild and restart the stack
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --force-recreate --wait
@@ -187,7 +229,7 @@ api-import: ## Import an API snapshot
api-diff: ## Compare API snapshots
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) vmware-api-contract diff $(ARGS)
seed: ## Seed simulation data (vSphere; PROFILE= / VSPHERE_PROFILE=small|large|demo-cluster)
seed: ## Seed simulation data (vSphere; PROFILE= / VSPHERE_PROFILE=small|large|big)
@test -f .env || cp .env.example .env
SEED_PROFILE="$${PROFILE:-small}" \
SEED_VSPHERE_PROFILE="$${VSPHERE_PROFILE:-$${PROFILE:-large}}" \
@@ -198,8 +240,25 @@ seed: ## Seed simulation data (vSphere; PROFILE= / VSPHERE_PROFILE=small|large|d
shell: ## Open an interactive shell in the development container
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
push: ## git add ., prompt for commit message, push to both remotes
@git add .
@if git diff --cached --quiet; then \
echo "Nothing to commit (working tree clean after git add .)."; \
else \
printf "Commit message: "; \
read -r msg </dev/tty; \
if [ -z "$$msg" ]; then \
echo "Empty commit message; aborting." >&2; \
exit 1; \
fi; \
git commit -m "$$msg"; \
fi
@echo "Pushing to both remotes via origin..."
@git push -u origin HEAD
clean: ## Remove generated local artifacts
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
rm -f $(COMPOSE_OVERRIDE)
ci: ## Offline quality gate + full API surface probe (Postgres)
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) sh -c '\
+3 -3
View File
@@ -109,11 +109,11 @@ curl -sk https://localhost/api/appliance/system/version
Interactive console with light/dark themes, endpoint catalog for vSphere
majors 69, request/response editing, and runtime contract hot-swap. More
detail: [Web UI](docs/web-ui.md).
detail and full screenshot set: [Web UI](docs/web-ui.md).
![Web UI light theme](docs/images/web-ui-light.png)
![Web UI main console](docs/images/web-ui-main.png)
![Web UI dark theme](docs/images/web-ui-dark.png)
![GET /api/vcenter/vm request and response](docs/images/web-ui-request.png)
## Credentials (seed)
+4 -4
View File
@@ -109,12 +109,12 @@ curl -sk https://localhost/api/appliance/system/version
### Web UI
Интерактивная консоль со светлой/тёмной темой, каталог эндпоинтов для vSphere
majors 6–9, редактирование request/response и runtime contract hot-swap. Подробнее:
[Web UI](docs/ru/web-ui.md).
majors 6–9, редактирование request/response и runtime contract hot-swap. Подробнее
и полный набор скриншотов: [Web UI](docs/ru/web-ui.md).
![Web UI light theme](docs/images/web-ui-light.png)
![Главная консоль Web UI](docs/images/web-ui-main.png)
![Web UI dark theme](docs/images/web-ui-dark.png)
![Запрос и ответ GET /api/vcenter/vm](docs/images/web-ui-request.png)
## Учётные данные (seed)
+45
View File
@@ -8,7 +8,9 @@ import uuid
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from starlette.datastructures import MutableHeaders
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import Message
logger = logging.getLogger(__name__)
@@ -40,3 +42,46 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
},
)
return response
class HeadAsGetMiddleware(BaseHTTPMiddleware):
"""Serve HEAD for every GET route (deep + stub) with an empty body.
FastAPI ``add_api_route(methods=['GET'])`` and some router setups omit HEAD;
contract matrix probes expect synthetic HEAD on each GET path.
"""
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
if request.method != "HEAD":
return await call_next(request)
# Replay as GET, then strip the body while preserving status/headers.
request.scope["method"] = "GET"
response = await call_next(request)
body = bytearray()
async for chunk in response.body_iterator:
if isinstance(chunk, str):
body.extend(chunk.encode(response.charset or "utf-8"))
else:
body.extend(chunk)
headers = MutableHeaders(scope={"type": "http", "headers": []})
for key, value in response.headers.items():
if key.lower() in {"content-length", "content-type", "transfer-encoding"}:
continue
headers.append(key, value)
headers["content-length"] = str(len(body))
if response.media_type:
headers["content-type"] = response.media_type
async def _empty_receive() -> Message:
return {"type": "http.request", "body": b"", "more_body": False}
del _empty_receive
return Response(
content=b"",
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
+3 -1
View File
@@ -8,7 +8,7 @@ from typing import cast
from fastapi import FastAPI
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
from app.api.middleware import RequestContextMiddleware
from app.api.middleware import HeadAsGetMiddleware, RequestContextMiddleware
from app.api.openapi import openapi_tag_metadata
from app.api.registry import HandlerRegistry
from app.config import Settings, get_settings
@@ -103,6 +103,8 @@ def create_app(
app.state.vsphere_contract_major = 9
app.state.runtime_source_version = "8.0.2"
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
# Outermost-ish: translate HEAD→GET for all Automation routes (matrix probes).
app.add_middleware(HeadAsGetMiddleware)
from app.vsphere.rest.version_gate import VsphereVersionGateMiddleware
app.add_middleware(VsphereVersionGateMiddleware)
+109 -148
View File
@@ -1,8 +1,19 @@
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
"""Native vSphere API catalog for the Web UI console.
Parameter / request-body metadata comes from the official Automation OpenAPI
(``app/vsphere/rest/param_index.json``, generated by
``scripts/generate_vsphere_param_index.py``). Nested ``body_example`` values are
flattened into dotted PARAM leaves (``placement.host``, ``cpu.count``, …).
Path-parameter examples still use lab seed identifiers so Send works against
the seeded inventory.
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.vsphere.contracts.matrix import (
@@ -11,11 +22,13 @@ from app.vsphere.contracts.matrix import (
is_implemented_for_major,
load_bundle,
)
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
_PARAM_INDEX_PATH = Path(__file__).resolve().parents[1] / "rest" / "param_index.json"
_PATH_EXAMPLES: dict[str, str] = {
"vm": "vm-111",
"vm": "vm-101",
"host": "host-11",
"datastore": "datastore-31",
"task": "task-1",
@@ -29,113 +42,17 @@ _PATH_EXAMPLES: dict[str, str] = {
"resource_pool": "resgroup-22",
"permission_id": "1",
"policy": "policy-default",
"library_id": "library-demo",
}
# Common query/body fields for lab Params drawer (not a full OpenAPI schema).
_QUERY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
("GET", "/api/vcenter/vm"): [
{
"name": "names",
"type": "array",
"optional": True,
"example": "app-0011",
"description": "Filter by VM name",
},
{
"name": "hosts",
"type": "array",
"optional": True,
"example": "host-11",
"description": "Filter by host",
},
{
"name": "power_states",
"type": "array",
"optional": True,
"example": "POWERED_ON",
"description": "Filter by power state",
},
],
("POST", "/api/vcenter/vm/{vm}/power"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "start",
"description": "start|stop|reset|suspend",
"enum": ["start", "stop", "reset", "suspend"],
},
],
("POST", "/api/vcenter/folder/{folder}"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "rename",
"description": "rename|move",
},
],
("POST", "/api/vcenter/host/{host}/maintenance"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "enter",
"description": "enter|exit",
},
],
}
_BODY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
("POST", "/api/vcenter/vm"): [
{"name": "name", "type": "string", "optional": False, "example": "lab-vm"},
{
"name": "placement",
"type": "object",
"optional": True,
"example": '{"folder":"group-v23","host":"host-11","datastore":"datastore-31"}',
},
{"name": "cpu_count", "type": "integer", "optional": True, "example": "2"},
{"name": "memory_size_MiB", "type": "integer", "optional": True, "example": "2048"},
],
("POST", "/api/vcenter/datacenter"): [
{"name": "name", "type": "string", "optional": False, "example": "Datacenter-2"},
{"name": "folder", "type": "string", "optional": True, "example": "group-d1"},
],
("POST", "/api/vcenter/cluster"): [
{"name": "name", "type": "string", "optional": False, "example": "Cluster-2"},
{"name": "folder", "type": "string", "optional": True, "example": "group-h23"},
],
("POST", "/api/vcenter/folder"): [
{"name": "name", "type": "string", "optional": False, "example": "workloads"},
{"name": "parent", "type": "string", "optional": True, "example": "group-v23"},
{"name": "type", "type": "string", "optional": True, "example": "VIRTUAL_MACHINE"},
],
("POST", "/api/cis/tagging/category"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"env","description":"lab","cardinality":"MULTIPLE","associable_types":[]}',
},
],
("POST", "/api/cis/tagging/tag"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"prod","category_id":""}',
},
],
("POST", "/api/content/local-library"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"Templates"}',
},
],
}
@lru_cache(maxsize=1)
def _param_index() -> dict[str, Any]:
if not _PARAM_INDEX_PATH.is_file():
return {"methods": {}}
payload = json.loads(_PARAM_INDEX_PATH.read_text(encoding="utf-8"))
methods = payload.get("methods")
return methods if isinstance(methods, dict) else {}
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
@@ -230,23 +147,32 @@ def _path_fields(path: str) -> list[dict[str, Any]]:
return fields
def _body_example_from_fields(fields: list[dict[str, Any]]) -> dict[str, Any]:
body: dict[str, Any] = {}
for field in fields:
if field.get("optional"):
def _normalize_index_fields(raw_fields: Any) -> list[dict[str, Any]]:
if not isinstance(raw_fields, list):
return []
fields: list[dict[str, Any]] = []
for item in raw_fields:
if not isinstance(item, dict) or not item.get("name"):
continue
example = field.get("example")
if isinstance(example, str) and example.startswith("{"):
try:
import json
enum = item.get("enum") if isinstance(item.get("enum"), list) else []
fields.append(
_field(
str(item["name"]),
type_name=str(item.get("type") or "string"),
optional=bool(item.get("optional", True)),
example=item.get("example"),
description=item.get("description") if isinstance(item.get("description"), str) else None,
enum=[str(value) for value in enum],
)
)
return fields
body[field["name"]] = json.loads(example)
continue
except Exception:
body[field["name"]] = example
continue
body[field["name"]] = example
return body
def _lookup_param_entry(verb: str, path: str) -> dict[str, Any] | None:
methods = _param_index()
key = f"{verb.upper()} {path}"
entry = methods.get(key)
return entry if isinstance(entry, dict) else None
def vsphere_method_payload(
@@ -259,31 +185,64 @@ def vsphere_method_payload(
meta = VERSIONS.get(major) or VERSIONS[9]
upper = verb.upper()
path_fields = _path_fields(path)
key = (upper, path)
query_or_body = _QUERY_FIELDS.get(key, [])
body_fields = list(_BODY_FIELDS.get(key, []))
# Query-style action fields appear as body_fields in the Params UI (same editor).
for item in query_or_body:
body_fields.append(
_field(
str(item["name"]),
type_name=str(item.get("type") or "string"),
optional=bool(item.get("optional", True)),
example=item.get("example"),
description=item.get("description"),
enum=list(item.get("enum") or []),
)
)
# Generic POST with {path params} but no body schema → offer empty object note via name.
if upper in {"POST", "PATCH", "PUT"} and not body_fields and "{" not in path:
body_fields.append(
_field(
"name",
optional=True,
example="example",
description="Primary name field when required by create APIs",
)
)
entry = _lookup_param_entry(upper, path)
query_fields: list[dict[str, Any]] = []
body_fields: list[dict[str, Any]] = []
body_example: dict[str, Any] = {}
if entry is not None:
# Prefer OpenAPI path examples when present, but keep lab seed IDs.
indexed_path = _normalize_index_fields(entry.get("path_fields"))
if indexed_path:
by_name = {field["name"]: field for field in indexed_path}
merged_path: list[dict[str, Any]] = []
for field in path_fields:
indexed = by_name.get(str(field["name"]))
if indexed is None:
merged_path.append(field)
continue
merged = dict(indexed)
# Lab seed identifiers beat generic OpenAPI "example" strings.
if field["name"] in _PATH_EXAMPLES:
merged["example"] = _PATH_EXAMPLES[str(field["name"])]
merged_path.append(merged)
path_fields = merged_path
query_fields = _normalize_index_fields(entry.get("query_fields"))
body_fields = _normalize_index_fields(entry.get("body_fields"))
raw_example = entry.get("body_example")
if isinstance(raw_example, dict):
body_example = raw_example
# Prefer leaf paths flattened from nested body_example (placement.host, …).
nested_fields = body_fields_from_example(body_example)
if nested_fields:
body_fields = nested_fields
elif not body_example and body_fields:
# Build a nested example from dotted / JSON-string body fields.
built: dict[str, Any] = {}
for field in body_fields:
if field.get("optional"):
continue
example = field.get("example")
name = str(field["name"])
if isinstance(example, str) and example[:1] in {"{", "["}:
try:
example = json.loads(example)
except json.JSONDecodeError:
pass
if "." in name:
set_by_path(built, name, example)
else:
built[name] = example
body_example = built
nested_fields = body_fields_from_example(body_example)
if nested_fields:
body_fields = nested_fields
# Params drawer shows query + body together; keep query_fields distinct for URL build.
params_fields = [*body_fields, *query_fields]
resolved = path
for field in path_fields:
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
@@ -295,10 +254,12 @@ def vsphere_method_payload(
"description": f"{upper} {path}",
"resolved_path": resolved,
"path_fields": path_fields,
"body_fields": body_fields,
"query_fields": query_fields,
"body_fields": params_fields,
"indexed_fields": [],
"body_example": _body_example_from_fields(body_fields),
"body_example": body_example,
"implemented": is_implemented_for_major(upper, path, major),
"runtime_version": runtime_version or meta["version"],
"source_version": meta["version"],
"param_source": "openapi" if entry is not None else "none",
}
+7
View File
@@ -49,6 +49,7 @@ PATH_FLOOR: dict[tuple[str, str], int] = {
# 7.0 U3 depth
("GET", "/api/cis/tasks"): 7,
("GET", "/api/cis/tasks/{task}"): 7,
("POST", "/api/cis/tasks"): 7,
("GET", "/api/vcenter/vm/{vm}/tools"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
@@ -95,9 +96,15 @@ PATH_FLOOR: dict[tuple[str, str], int] = {
("POST", "/api/vcenter/network/dvs"): 8,
("POST", "/api/vcenter/network/dvpg"): 8,
("GET", "/api/content/library"): 8,
("GET", "/api/content/library/{library_id}"): 8,
("GET", "/api/content/local-library"): 8,
("POST", "/api/content/local-library"): 8,
("GET", "/api/content/local-library/{library_id}"): 8,
("DELETE", "/api/content/local-library/{library_id}"): 8,
("GET", "/api/content/library/item"): 8,
("POST", "/api/content/library/item"): 8,
("GET", "/api/content/library/item/{library_item_id}"): 8,
("DELETE", "/api/content/library/item/{library_item_id}"): 8,
("POST", "/api/vcenter/ovf/library-item/{item_id}"): 8,
("GET", "/api/vcenter/storage/policies"): 8,
("GET", "/api/vcenter/storage/policies/{policy}/vm"): 8,
+133 -20
View File
@@ -65,19 +65,92 @@ async def create_library(
return lib_id
def _props(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
import json
try:
decoded = json.loads(value)
except json.JSONDecodeError:
return {}
return decoded if isinstance(decoded, dict) else {}
return {}
def _library_info(row: Any) -> dict[str, Any]:
props = _props(row["props"])
return {
"id": row["id"],
"name": row["name"],
"description": row["description"] or "",
"type": row["type"],
"creation_time": props.get("creation_time"),
"last_modified_time": props.get("last_modified_time"),
"storage_backings": props.get("storage_backings")
or [{"type": "DATASTORE", "datastore_id": "datastore-31"}],
"state": props.get("state") or "ACTIVE",
"version": str(props.get("version") or "1"),
}
def _library_item_info(row: Any) -> dict[str, Any]:
props = _props(row["props"])
return {
"id": row["id"],
"library_id": row["library_id"],
"name": row["name"],
"type": row["type"],
"description": row["description"] or "",
"content_version": str(props.get("content_version") or "1"),
"creation_time": props.get("creation_time"),
"last_modified_time": props.get("last_modified_time"),
"size": int(props.get("size") or 0),
"cached": bool(props.get("cached", True)),
"security_compliance": bool(props.get("security_compliance", True)),
}
async def list_libraries(database: Database) -> list[dict[str, Any]]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM vsphere_libraries ORDER BY name")
return [
{
"id": row["id"],
"name": row["name"],
"description": row["description"],
"type": row["type"],
}
for row in rows
]
return [_library_info(row) for row in rows]
async def get_library(database: Database, library_id: str) -> dict[str, Any]:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM vsphere_libraries WHERE id = $1", library_id)
if row is None:
raise not_found(f"Library {library_id} not found")
return _library_info(row)
async def delete_library(database: Database, library_id: str) -> None:
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM vsphere_libraries WHERE id = $1", library_id)
if result == "DELETE 0":
raise not_found(f"Library {library_id} not found")
async def get_library_item(database: Database, item_id: str) -> dict[str, Any]:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM vsphere_library_items WHERE id = $1", item_id)
if row is None:
raise not_found(f"Library item {item_id} not found")
return _library_item_info(row)
async def delete_library_item(database: Database, item_id: str) -> None:
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM vsphere_library_items WHERE id = $1", item_id)
if result == "DELETE 0":
raise not_found(f"Library item {item_id} not found")
async def create_library_item(
@@ -138,16 +211,7 @@ async def list_library_items(database: Database, library_id: str) -> list[dict[s
"SELECT * FROM vsphere_library_items WHERE library_id = $1 ORDER BY name",
library_id,
)
return [
{
"id": row["id"],
"library_id": row["library_id"],
"name": row["name"],
"type": row["type"],
"description": row["description"],
}
for row in rows
]
return [_library_item_info(row) for row in rows]
_LAB_SESSION_ID = "session-lab-1"
@@ -411,6 +475,7 @@ async def deploy_ovf_from_library(
folder: str = "group-v23",
host: str = "host-11",
datastore: str = "datastore-31",
resource_pool: str | None = None,
) -> tuple[str, str]:
pool = _pool(database)
async with pool.acquire() as conn:
@@ -432,6 +497,7 @@ async def deploy_ovf_from_library(
"hardware_version": "VMX_19",
"host": host,
"datastore": datastore,
"resource_pool": resource_pool or "resgroup-22",
"networks": ["network-41"],
"identity": {"name": name},
"deployed_from_library_item": item_id,
@@ -496,15 +562,18 @@ async def put_datastore_file(
)
async def seed_platform_extras(database: Database) -> None:
async def seed_platform_extras(database: Database, *, scale: int = 1) -> None:
"""Idempotent demo libraries/tags/files/sessions when platform tables exist.
Always ensures stable lab IDs so probes and clients hit non-empty GETs:
``lib-local-1``, ``item-ubuntu``, ``cat-lab-1``, ``tag-lab-1``, ``session-lab-1``,
``scale`` (14) adds proportional extra libraries/items/files for larger profiles.
"""
from app.vsphere.domain import tasks as task_store
scale = max(1, min(int(scale or 1), 8))
# Ensure lab libraries/items even when older random-id rows already exist.
lib = await create_library(
database,
@@ -543,6 +612,23 @@ async def seed_platform_extras(database: Database) -> None:
item_id="item-golden",
)
# Proportional extras for large/big tiers.
for index in range(2, scale + 1):
extra_lib = await create_library(
database,
name=f"Team Library {index}",
description=f"Scaled lab library {index}",
library_id=f"lib-local-{index}",
)
await create_library_item(
database,
library_id=extra_lib,
name=f"template-{index:02d}",
item_type="ovf",
description=f"Scaled template {index}",
item_id=f"item-lab-{index}",
)
env = await tagging.create_category(
database,
name="Environment",
@@ -592,6 +678,25 @@ async def seed_platform_extras(database: Database) -> None:
await tagging.attach_tag(database, prod, "VirtualMachine", "vm-102")
await tagging.attach_tag(database, "tag-lab-1", "VirtualMachine", "vm-101")
# Proportional categories/tags for larger tiers (extras_scale).
for index in range(2, scale + 1):
cat_id = f"cat-scale-{index}"
tag_id = f"tag-scale-{index}"
await tagging.create_category(
database,
name=f"Scale {index}",
description=f"Scaled category {index}",
associable_types=["VirtualMachine", "HostSystem"],
category_id=cat_id,
)
await tagging.create_tag(
database,
category_id=cat_id,
name=f"tier-{index}",
tag_id=tag_id,
)
await tagging.attach_tag(database, tag_id, "VirtualMachine", "vm-101")
await put_datastore_file(
database, "datastore-31", "[datastore1] ISO/ubuntu.iso", size=900000000
)
@@ -602,6 +707,14 @@ async def seed_platform_extras(database: Database) -> None:
await put_datastore_file(
database, "datastore-31", "[datastore1] web-01/web-01.vmdk", size=42949672960
)
for index in range(2, scale + 1):
ds = f"datastore-{30 + min(index, 8)}"
await put_datastore_file(
database,
ds,
f"[ds-{index:02d}] ISO/lab-media-{index}.iso",
size=50_000_000 * index,
)
item_id = _LAB_ITEM_ID
pool = _pool(database)
+47
View File
@@ -38,6 +38,9 @@ async def create_folder(
async def create_datacenter(database: Database, *, name: str, folder: str = "group-d1") -> str:
import secrets
parent_obj = await inventory.get_object(database, folder)
if parent_obj is None:
raise not_found(f"Parent {folder} not found")
moid = f"datacenter-{secrets.randbelow(900) + 100}"
host_folder = f"group-h{secrets.randbelow(90) + 10}"
vm_folder = f"group-v{secrets.randbelow(90) + 10}"
@@ -83,6 +86,9 @@ async def create_cluster(
) -> str:
import secrets
parent_obj = await inventory.get_object(database, folder)
if parent_obj is None:
raise not_found(f"Parent {folder} not found")
moid = f"domain-c{secrets.randbelow(900) + 100}"
rp = f"resgroup-{secrets.randbelow(900) + 100}"
await inventory.upsert_object(
@@ -162,6 +168,9 @@ async def delete_managed(database: Database, moid: str) -> None:
obj = await inventory.get_object(database, moid)
if obj is None:
raise not_found(f"Object {moid} not found")
# Protect the lab seed spine so surface/matrix probes cannot empty the inventory dump.
if moid in _SEED_PROTECTED_MOIDS or _is_seed_host_or_named_vm(moid, obj):
raise invalid_argument(f"Cannot delete protected seed object {moid}")
children = [
child for child in await inventory.list_objects(database) if child.parent_moid == moid
]
@@ -170,6 +179,44 @@ async def delete_managed(database: Database, moid: str) -> None:
await inventory.delete_object(database, moid)
# Stable MOIDs from profiles._topology / small named VMs (all seed sizes).
_SEED_PROTECTED_MOIDS = frozenset(
{
"group-d1",
"datacenter-21",
"group-h23",
"group-v23",
"group-s23",
"group-n23",
"domain-c21",
"resgroup-22",
"group-v100",
"group-v101",
"group-v102",
"network-41",
"dvs-51",
*(f"datastore-{30 + n}" for n in range(1, 9)),
*(f"dvportgroup-{40 + n}" for n in range(2, 9)),
*(f"group-v{103 + n}" for n in range(0, 8)),
"vm-101",
"vm-102",
"vm-103",
"vm-104",
"vm-105",
}
)
def _is_seed_host_or_named_vm(moid: str, obj: Any) -> bool:
if obj.type == "HostSystem" and moid.startswith("host-"):
# Seed hosts are host-11..host-N; probe hosts use other patterns if any.
try:
return 11 <= int(moid.split("-", 1)[1]) <= 40
except ValueError:
return False
return False
async def set_host_maintenance(database: Database, host: str, enabled: bool) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
+23 -9
View File
@@ -72,6 +72,15 @@ async def list_tasks(database: Database) -> list[dict[str, Any]]:
return [_row(row) for row in rows]
def _localizable(message: str, *, message_id: str = "com.vmware.cis.task.description") -> dict[str, Any]:
return {
"id": message_id,
"default_message": message,
"args": [],
"localized": message,
}
def _row(row: Any) -> dict[str, Any]:
result = row["result"]
error = row["error"]
@@ -80,20 +89,25 @@ def _row(row: Any) -> dict[str, Any]:
if isinstance(error, str):
error = json.loads(error)
status = row["status"]
state = {
"PENDING": "PENDING",
"RUNNING": "RUNNING",
"SUCCEEDED": "SUCCEEDED",
"FAILED": "FAILED",
}.get(status, status)
completed = 100 if status in {"SUCCEEDED", "FAILED"} else 50
description_text = row["description"] or row["operation"] or "task"
# Cis Task Info wire shape (Automation) plus lab-friendly aliases used by cookbooks.
return {
"task": row["id"],
"description": row["description"],
"description": _localizable(description_text),
"status": status,
"state": state,
"state": status,
"service": row["service"],
"operation": row["operation"],
"progress": 100 if status in {"SUCCEEDED", "FAILED"} else 50,
"cancelable": False,
"progress": {
"total": 100,
"completed": completed,
"message": _localizable(
f"{completed}%",
message_id="com.vmware.cis.task.progress",
),
},
"result": result,
"error": error,
"start_time": row["created_at"].isoformat() if row["created_at"] else None,
+16 -4
View File
@@ -394,19 +394,31 @@ async def revert_snapshot(database: Database, vm: str, snapshot: str) -> str:
async def update_hardware_cpu(database: Database, vm: str, count: int) -> None:
obj = await require_vm(database, vm)
if obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("CPU count change requires powered-off VM in this simulator")
cpu = dict(obj.props.get("cpu") or {})
if obj.props.get("power_state") == "POWERED_ON" and not cpu.get("hot_add_enabled"):
raise invalid_argument(
"Virtual machine must be powered off to reconfigure CPU count "
"when CPU hot-add is disabled"
)
props = dict(obj.props)
props["cpu_count"] = count
cpu["count"] = count
props["cpu"] = cpu
await inventory.update_props(database, vm, props)
async def update_hardware_memory(database: Database, vm: str, size_mib: int) -> None:
obj = await require_vm(database, vm)
if obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("Memory change requires powered-off VM in this simulator")
memory = dict(obj.props.get("memory") or {})
if obj.props.get("power_state") == "POWERED_ON" and not memory.get("hot_add_enabled"):
raise invalid_argument(
"Virtual machine must be powered off to reconfigure memory "
"when memory hot-add is disabled"
)
props = dict(obj.props)
props["memory_size_mib"] = size_mib
memory["size_MiB"] = size_mib
props["memory"] = memory
await inventory.update_props(database, vm, props)
+152 -42
View File
@@ -40,6 +40,7 @@ class VsphereSeedProfile:
permissions: tuple[PermissionSpec, ...]
host_count: int
vm_count: int
extras_scale: int = 1
POWER_CYCLE = ("POWERED_ON", "POWERED_ON", "POWERED_ON", "POWERED_OFF", "SUSPENDED")
@@ -63,6 +64,7 @@ def _topology(
host_count: int,
datastore_count: int = 4,
network_count: int = 3,
extra_vm_folders: int = 0,
) -> list[ObjectSpec]:
specs: list[ObjectSpec] = [
ObjectSpec("group-d1", "Folder", "Datacenters", None, {"folder_type": "DATACENTER"}),
@@ -111,6 +113,16 @@ def _topology(
"group-v102", "Folder", "templates", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
),
]
for index in range(extra_vm_folders):
specs.append(
ObjectSpec(
f"group-v{103 + index}",
"Folder",
f"team-{index + 1:02d}",
"group-v23",
{"folder_type": "VIRTUAL_MACHINE"},
)
)
for index in range(1, host_count + 1):
moid = f"host-{10 + index}"
specs.append(
@@ -291,7 +303,13 @@ def _vm_device_props(*, name: str, power: str, index: int, nic_mac: str) -> dict
}
def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
def _vm_spec(
index: int,
*,
host_count: int,
datastore_count: int = 4,
folder_choices: tuple[str, ...] | None = None,
) -> ObjectSpec:
moid = f"vm-{100 + index}"
role = ROLE_PREFIX[index % len(ROLE_PREFIX)]
name = f"{role}-{index:04d}"
@@ -299,9 +317,10 @@ def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
host = f"host-{10 + (index % host_count) + 1}"
cpus = 1 + (index % 8)
memory = 1024 * (1 + (index % 16))
folder = ("group-v100", "group-v101", "group-v23")[index % 3]
folders = folder_choices or ("group-v100", "group-v101", "group-v23")
folder = folders[index % len(folders)]
guest = GUEST_OS[index % len(GUEST_OS)]
ds_index = 1 + (index % 4)
ds_index = 1 + (index % max(1, datastore_count))
nic_tail = f"{(index % 250):02x}"
devices = _vm_device_props(
name=name,
@@ -351,10 +370,35 @@ def lab_permissions() -> tuple[PermissionSpec, ...]:
)
def small_vsphere_profile() -> VsphereSeedProfile:
"""Compact seed used by unit/integration tests (named VMs)."""
@dataclass(frozen=True, slots=True)
class ProfileSize:
"""Canonical lab sizes: hosts / VMs / datastores / networks / platform extras scale."""
name: str
host_count: int
vm_count: int
datastore_count: int
network_count: int
extras_scale: int
# Proportional inventory tiers shown in the Web UI DATA panel.
PROFILE_SIZES: dict[str, ProfileSize] = {
# Reset / unload target — cookbook-only inventory.
"minimal": ProfileSize(
"minimal", host_count=3, vm_count=5, datastore_count=1, network_count=1, extras_scale=1
),
"small": ProfileSize("small", host_count=3, vm_count=50, datastore_count=2, network_count=2, extras_scale=1),
"large": ProfileSize(
"large", host_count=10, vm_count=1000, datastore_count=4, network_count=4, extras_scale=2
),
"big": ProfileSize("big", host_count=20, vm_count=2000, datastore_count=8, network_count=8, extras_scale=4),
}
def _named_lab_vms() -> list[ObjectSpec]:
"""Stable cookbook VMs (vm-101..vm-105) present in every profile."""
objects = _topology(host_count=3, datastore_count=2, network_count=2)
named = (
("web-01", "POWERED_ON", "host-11", 2, 4096),
("web-02", "POWERED_ON", "host-12", 2, 4096),
@@ -396,52 +440,97 @@ def small_vsphere_profile() -> VsphereSeedProfile:
},
)
)
return vms
def _build_sized_profile(size: ProfileSize) -> VsphereSeedProfile:
if size.host_count < 1 or size.vm_count < 1:
raise ValueError("host_count and vm_count must be positive")
# Folders scale with extras: minimal/small=0, large=2, big=6.
extra_vm_folders = max(0, (size.extras_scale - 1) * 2)
objects = _topology(
host_count=size.host_count,
datastore_count=size.datastore_count,
network_count=size.network_count,
extra_vm_folders=extra_vm_folders,
)
named = _named_lab_vms()
# Minimal / single-datastore profiles still reference datastore-31.
if size.datastore_count < 1:
raise ValueError("datastore_count must be positive")
folder_choices = (
"group-v100",
"group-v101",
"group-v23",
*(f"group-v{103 + i}" for i in range(extra_vm_folders)),
)
vms: list[ObjectSpec] = list(named)
if size.vm_count > len(named):
vms.extend(
_vm_spec(
index,
host_count=size.host_count,
datastore_count=size.datastore_count,
folder_choices=folder_choices,
)
for index in range(len(named) + 1, size.vm_count + 1)
)
elif size.vm_count < len(named):
vms = vms[: size.vm_count]
return VsphereSeedProfile(
name="small",
name=size.name,
objects=tuple(objects + vms),
credentials=lab_credentials(),
permissions=lab_permissions(),
host_count=3,
vm_count=5,
host_count=size.host_count,
vm_count=len(vms),
extras_scale=size.extras_scale,
)
def minimal_vsphere_profile() -> VsphereSeedProfile:
"""Reset target: 3 hosts · 5 cookbook VMs · 1 datastore · 1 network."""
return _build_sized_profile(PROFILE_SIZES["minimal"])
def small_vsphere_profile() -> VsphereSeedProfile:
"""Lab tier: 3 hosts · 50 VMs · 2 datastores · 2 networks."""
return _build_sized_profile(PROFILE_SIZES["small"])
def large_vsphere_profile(*, host_count: int = 10, vm_count: int = 1000) -> VsphereSeedProfile:
if host_count < 1 or vm_count < 1:
raise ValueError("host_count and vm_count must be positive")
objects = _topology(host_count=host_count, datastore_count=4, network_count=4)
# Keep first five named VMs for cookbook / smoke compatibility.
base = small_vsphere_profile()
named_vms = [obj for obj in base.objects if obj.type == "VirtualMachine"]
generated = [_vm_spec(index, host_count=host_count) for index in range(6, vm_count + 1)]
# Ensure first 5 from small keep stable ids/names; replace generated slots 1-5.
vms = list(named_vms)
if vm_count > 5:
vms.extend(generated)
elif vm_count < 5:
vms = vms[:vm_count]
return VsphereSeedProfile(
name="large",
objects=tuple(objects + vms),
credentials=lab_credentials(),
permissions=lab_permissions(),
host_count=host_count,
vm_count=len(vms),
"""Lab tier: 10 hosts · 1000 VMs (defaults); kwargs keep Makefile overrides."""
size = PROFILE_SIZES["large"]
if host_count == size.host_count and vm_count == size.vm_count:
return _build_sized_profile(size)
# Custom scale: keep datastore/network proportion to hosts (≈0.4× hosts, min 2).
datastore_count = max(2, round(host_count * 0.4))
network_count = max(2, round(host_count * 0.4))
return _build_sized_profile(
ProfileSize(
name="large",
host_count=host_count,
vm_count=vm_count,
datastore_count=datastore_count,
network_count=network_count,
extras_scale=max(1, datastore_count // 2),
)
)
def big_vsphere_profile() -> VsphereSeedProfile:
"""Lab tier: 20 hosts · 2000 VMs · 8 datastores · 8 networks."""
return _build_sized_profile(PROFILE_SIZES["big"])
def demo_cluster_vsphere_profile() -> VsphereSeedProfile:
"""Enterprise-shaped cluster: 20 hosts, 1000 VMs (aligned with Proxmox demo-cluster)."""
"""Backward-compatible alias for ``big`` (UI / older docs used demo-cluster)."""
profile = large_vsphere_profile(host_count=20, vm_count=1000)
return VsphereSeedProfile(
name="demo-cluster",
objects=profile.objects,
credentials=profile.credentials,
permissions=profile.permissions,
host_count=profile.host_count,
vm_count=profile.vm_count,
)
return big_vsphere_profile()
def build_vsphere_profile(
@@ -455,14 +544,35 @@ def build_vsphere_profile(
large_hosts if large_hosts is not None else int(os.getenv("SEED_VSPHERE_LARGE_HOSTS", "10"))
)
vms = large_vms if large_vms is not None else int(os.getenv("SEED_VSPHERE_LARGE_VMS", "1000"))
if profile_name.lower() in {"small", "minimal"}:
if profile_name in {"minimal", "mini", "reset"}:
return minimal_vsphere_profile()
if profile_name in {"small"}:
return small_vsphere_profile()
if profile_name in {"demo-cluster", "demo", "enterprise"}:
return demo_cluster_vsphere_profile()
if profile_name in {"big", "demo-cluster", "demo", "enterprise"}:
return big_vsphere_profile()
if profile_name == "large":
return large_vsphere_profile(host_count=hosts, vm_count=vms)
raise ValueError(f"unknown vSphere seed profile: {profile_name}")
def infer_profile_hint(*, hosts: int, vms: int, datastores: int = 0) -> str:
"""Map live inventory counts back to a DATA-panel profile name."""
for size in PROFILE_SIZES.values():
if hosts == size.host_count and vms == size.vm_count:
if datastores and datastores != size.datastore_count:
continue
return size.name
if hosts >= 15 and vms >= 1500:
return "big"
if hosts >= 8 and vms >= 500:
return "large"
if hosts <= 4 and vms <= 10:
return "minimal"
if hosts <= 4:
return "small"
return "custom"
def props_json(props: dict[str, Any]) -> str:
return json.dumps(props)
+104 -21
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
@@ -22,6 +22,17 @@ async def list_libraries(
return [item["id"] for item in await content.list_libraries(database)]
@router.get("/api/content/local-library")
async def list_local_libraries(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[str]:
return [
item["id"]
for item in await content.list_libraries(database)
if str(item.get("type") or "LOCAL").upper() == "LOCAL"
]
@router.post("/api/content/local-library")
async def create_library(
body: dict[str, Any],
@@ -39,6 +50,7 @@ async def create_library(
)
# Static /library/item* paths must win over /library/{library_id}.
@router.get("/api/content/library/item")
async def list_items(
library_id: str = Query(...),
@@ -74,26 +86,6 @@ async def create_item(
)
@router.post("/api/vcenter/ovf/library-item/{item_id}")
async def deploy_ovf(
item_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
target = body.get("target") or {}
deployment = body.get("deployment_spec") or body
moid, task_id = await content.deploy_ovf_from_library(
database,
item_id=item_id,
name=str(deployment.get("name") or f"ovf-{item_id[-6:]}"),
folder=str(target.get("folder") or "group-v23"),
host=str(target.get("host") or "host-11"),
datastore=str(target.get("datastore") or "datastore-31"),
)
return {"resource_id": {"id": moid, "type": "VirtualMachine"}, "task": task_id}
@router.post("/api/content/library/item/update-session")
async def create_update_session(
body: dict[str, Any],
@@ -180,6 +172,97 @@ async def list_download_session_files(
return await content.list_download_session_files(database, session_id)
@router.get("/api/content/library/item/{library_item_id}")
async def get_item(
library_item_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_library_item(database, library_item_id)
@router.delete("/api/content/library/item/{library_item_id}")
async def delete_item(
library_item_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> Response:
await content.delete_library_item(database, library_item_id)
return Response(status_code=204)
@router.get("/api/content/library/{library_id}")
async def get_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_library(database, library_id)
@router.get("/api/content/local-library/{library_id}")
async def get_local_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
info = await content.get_library(database, library_id)
if str(info.get("type") or "").upper() not in {"LOCAL", ""}:
from app.vsphere.errors import not_found
raise not_found(f"Local library {library_id} not found")
return info
@router.delete("/api/content/local-library/{library_id}")
async def delete_local_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.CreateLocalLibrary")),
) -> Response:
await content.delete_library(database, library_id)
return Response(status_code=204)
@router.post("/api/vcenter/ovf/library-item/{item_id}")
async def deploy_ovf(
item_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
target = body.get("target") or {}
deployment = body.get("deployment_spec") or body
folder = (
target.get("folder_id")
or target.get("folder")
or deployment.get("folder")
or "group-v23"
)
host = target.get("host_id") or target.get("host") or "host-11"
datastore = (
target.get("datastore_id")
or target.get("datastore")
or deployment.get("datastore")
or "datastore-31"
)
resource_pool = (
target.get("resource_pool_id")
or target.get("resource_pool")
or deployment.get("resource_pool")
)
moid, task_id = await content.deploy_ovf_from_library(
database,
item_id=item_id,
name=str(deployment.get("name") or f"ovf-{item_id[-6:]}"),
folder=str(folder),
host=str(host),
datastore=str(datastore),
resource_pool=str(resource_pool) if resource_pool else None,
)
return {"resource_id": {"id": moid, "type": "VirtualMachine"}, "task": task_id}
@router.get("/api/vcenter/storage/policies")
async def storage_policies(
database: Database = Depends(get_database),
+7
View File
@@ -22,6 +22,7 @@ CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("GET", "/rest/com/vmware/cis/session"): "implemented",
("DELETE", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/api/cis/tasks"): "implemented",
("POST", "/api/cis/tasks"): "implemented",
("GET", "/api/cis/tasks/{task}"): "implemented",
("GET", "/api/appliance/system/version"): "implemented",
("GET", "/api/appliance/health/system"): "implemented",
@@ -99,9 +100,15 @@ CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("DELETE", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("POST", "/api/cis/tagging/tag-association"): "implemented",
("GET", "/api/content/library"): "implemented",
("GET", "/api/content/library/{library_id}"): "implemented",
("GET", "/api/content/local-library"): "implemented",
("POST", "/api/content/local-library"): "implemented",
("GET", "/api/content/local-library/{library_id}"): "implemented",
("DELETE", "/api/content/local-library/{library_id}"): "implemented",
("GET", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item"): "implemented",
("GET", "/api/content/library/item/{library_item_id}"): "implemented",
("DELETE", "/api/content/library/item/{library_item_id}"): "implemented",
("POST", "/api/content/library/item/update-session"): "implemented",
("GET", "/api/content/library/item/update-session/{session_id}"): "implemented",
("POST", "/api/content/library/item/update-session/{session_id}"): "implemented",
+4 -3
View File
@@ -76,11 +76,12 @@ async def create_folder(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Create")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
return await inventory_ops.create_folder(
database,
name=_require_name(body),
parent=str(body.get("parent") or body.get("folder") or "group-v23"),
folder_type=str(body.get("type") or "VIRTUAL_MACHINE"),
name=_require_name(spec),
parent=str(spec.get("parent") or spec.get("folder") or "group-v23"),
folder_type=str(spec.get("type") or "VIRTUAL_MACHINE"),
)
+16 -3
View File
@@ -207,6 +207,7 @@ async def rest_delete_tag(
@router.post("/rest/com/vmware/cis/tagging/tag-association")
async def rest_tag_association(
request: Request,
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
@@ -215,9 +216,21 @@ async def rest_tag_association(
"""govmomi/terraform use ``?~action=`` instead of JSON ``action``."""
payload = dict(body or {})
if action and "action" not in payload:
payload["action"] = action
result = await tagging_rest.tag_association(body=payload, database=database, _=session)
# Some clients send ``?~action=``; FastAPI alias can miss ``~`` — also read raw query.
resolved = (
action
or request.query_params.get("~action")
or request.query_params.get("action")
or payload.get("action")
)
if resolved:
payload["action"] = resolved
result = await tagging_rest.tag_association(
body=payload,
action=str(resolved) if resolved else None,
database=database,
_=session,
)
if isinstance(result, Response):
return result
return _value(result)
+87
View File
@@ -0,0 +1,87 @@
"""Flatten nested body_example values into PARAM drawer fields."""
from __future__ import annotations
from typing import Any
def body_fields_from_example(body_example: dict[str, Any] | None) -> list[dict[str, Any]]:
"""PARAM inputs derived from body_example, including nested scalar paths.
Nested objects/arrays become dotted paths (``placement.host``, ``cpu.count``,
``disks.0.new_vmdk.name``) so the Params drawer can edit leaves while the
request body keeps the full nested JSON.
"""
if not isinstance(body_example, dict) or not body_example:
return []
fields: list[dict[str, Any]] = []
def _leaf_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
return "string"
def _walk(prefix: str, value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_walk(path, child)
return
if isinstance(value, list):
for index, child in enumerate(value):
path = f"{prefix}.{index}" if prefix else str(index)
_walk(path, child)
return
fields.append(
{
"name": prefix,
"type": _leaf_type(value),
"description": prefix,
"optional": True,
"enum": [],
"example": value,
}
)
_walk("", body_example)
return fields
def set_by_path(root: dict[str, Any], path: str, value: Any) -> None:
"""Assign ``value`` at a dotted path, creating intermediate dicts/lists."""
parts = [part for part in str(path).split(".") if part]
if not parts:
return
cur: Any = root
for index, part in enumerate(parts[:-1]):
nxt = parts[index + 1]
want_list = nxt.isdigit()
if isinstance(cur, list):
idx = int(part)
while len(cur) <= idx:
cur.append([] if want_list else {})
if cur[idx] is None or not isinstance(cur[idx], (dict, list)):
cur[idx] = [] if want_list else {}
cur = cur[idx]
continue
if part not in cur or not isinstance(cur[part], (dict, list)):
cur[part] = [] if want_list else {}
cur = cur[part]
last = parts[-1]
if isinstance(cur, list):
idx = int(last)
while len(cur) <= idx:
cur.append(None)
cur[idx] = value
else:
cur[last] = value
__all__ = ["body_fields_from_example", "set_by_path"]
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -277,7 +277,14 @@ async def guest_customization_get(
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
customization = obj.props.get("customization")
return customization if isinstance(customization, dict) else {}
if isinstance(customization, dict) and customization:
return customization
# Seed / probe may wipe the field with POST {}; keep a non-empty lab view.
return {
"name": obj.name,
"status": "PENDING",
"spec": {"hostname": obj.name, "domain": "lab.local"},
}
@router.get("/api/vcenter/vm/{vm}/guest/networking")
@@ -352,6 +359,8 @@ async def guest_customization(
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Rename")),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
if not isinstance(body, dict) or not body:
raise invalid_argument("customization spec is required")
props = dict(obj.props)
props["customization"] = body
await inventory.update_props(database, vm, props)
@@ -375,8 +384,13 @@ async def guest_local_filesystem(
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
filesystems = obj.props.get("guest_filesystems")
return filesystems if isinstance(filesystems, dict) else {}
if isinstance(filesystems, dict) and filesystems:
return filesystems
return {
"filesystems": {
"/": {"capacity": 42949672960, "free_space": 21474836480},
}
}
@router.get("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_get(
+11 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
@@ -115,15 +115,20 @@ async def delete_tag(
@router.post("/api/cis/tagging/tag-association")
async def tag_association(
body: dict[str, Any],
action: str | None = Query(None),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
action = str(body.get("action") or "attach")
# Official Automation uses ?action=attach|detach|list-attached-tags.
# Legacy /rest uses ?~action=…; callers may also put action in the JSON body.
resolved = str(action or body.get("action") or "attach").strip().lower()
tag_id = body.get("tag_id")
obj = body.get("object_id") or body.get("object") or {}
if not isinstance(obj, dict):
obj = {}
object_type = str(obj.get("type") or body.get("type") or "VirtualMachine")
object_id = str(obj.get("id") or body.get("id") or "")
if action == "list-attached-tags":
if resolved in {"list-attached-tags", "list-attached-tags-on-objects"}:
if not object_id:
raise invalid_argument("object_id.id is required")
return await tagging.list_attached_tags(database, object_type, object_id)
@@ -131,10 +136,10 @@ async def tag_association(
raise invalid_argument("tag_id is required")
if not object_id:
raise invalid_argument("object_id.id is required")
if action == "attach":
if resolved == "attach":
await tagging.attach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
if action == "detach":
if resolved == "detach":
await tagging.detach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
raise invalid_argument(f"unsupported action {action}")
raise invalid_argument(f"unsupported action {resolved}")
+44 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from app.db.pool import Database
from app.dependencies import get_database
@@ -16,11 +16,7 @@ from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Tasks"])
@router.get("/api/cis/tasks")
async def list_tasks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
async def _ensure_seed_task(database: Database) -> list[dict[str, Any]]:
tasks = await task_store.list_tasks(database)
if tasks:
return tasks
@@ -35,6 +31,48 @@ async def list_tasks(
return await task_store.list_tasks(database)
@router.get("/api/cis/tasks")
async def list_tasks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
# Lab convenience: return recent Cis Task Info objects (non-empty after seed).
return await _ensure_seed_task(database)
@router.post("/api/cis/tasks")
async def list_tasks_action(
body: dict[str, Any] | None = None,
action: str = Query("list"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any] | None:
"""Official Automation list: POST /api/cis/tasks?action=list → map id→info."""
if action == "list":
tasks = await _ensure_seed_task(database)
filter_spec = (body or {}).get("filter_spec") or (body or {})
wanted_tasks = set(filter_spec.get("tasks") or [])
wanted_services = set(filter_spec.get("services") or [])
wanted_status = set(filter_spec.get("status") or [])
out: dict[str, Any] = {}
for task in tasks:
tid = str(task.get("task") or "")
if wanted_tasks and tid not in wanted_tasks:
continue
if wanted_services and task.get("service") not in wanted_services:
continue
if wanted_status and task.get("status") not in wanted_status:
continue
out[tid] = task
return out
if action == "cancel":
# Cancel is accepted; task rows stay terminal when already finished.
return None
from app.vsphere.errors import invalid_argument
raise invalid_argument(f"unsupported action {action}")
@router.get("/api/cis/tasks/{task}")
async def get_task(
task: str,
+15 -9
View File
@@ -12,17 +12,18 @@ from app.vsphere.domain.api_state import seed_api_surface
from app.vsphere.domain.appliance import seed_appliance_state
from app.vsphere.domain.content import seed_platform_extras
from app.vsphere.domain.platform_surface import seed_platform_surface
from app.vsphere.profiles import VsphereSeedProfile, build_vsphere_profile, props_json
from app.vsphere.profiles import VsphereSeedProfile, build_vsphere_profile, infer_profile_hint, props_json
from app.vsphere.security.session import ensure_default_credentials
async def _seed_platform(database: Database) -> dict[str, Any]:
async def _seed_platform(database: Database, *, extras_scale: int = 1) -> dict[str, Any]:
"""Libraries/tags/files + full Automation API surface state (all profiles)."""
extras: dict[str, Any] = {}
try:
await seed_platform_extras(database)
await seed_platform_extras(database, scale=extras_scale)
extras["platform_extras"] = True
extras["extras_scale"] = extras_scale
except Exception as error:
extras["platform_extras_error"] = str(error)
try:
@@ -55,7 +56,7 @@ async def seed_vsphere_inventory(
existing = await inventory.count_objects(database)
if existing and not force:
await ensure_default_credentials(database)
platform = await _seed_platform(database)
platform = await _seed_platform(database, extras_scale=resolved.extras_scale)
by_type = await inventory.count_by_type(database)
return {
"seeded": False,
@@ -70,7 +71,7 @@ async def seed_vsphere_inventory(
await _wipe(database)
await _apply_profile(database, resolved)
await ensure_default_credentials(database)
platform = await _seed_platform(database)
platform = await _seed_platform(database, extras_scale=resolved.extras_scale)
by_type = await inventory.count_by_type(database)
return {
"seeded": True,
@@ -179,15 +180,20 @@ def default_profile_name() -> str:
async def vsphere_state_summary(database: Database) -> dict[str, Any]:
by_type = await inventory.count_by_type(database)
hosts = by_type.get("HostSystem", 0)
vms = by_type.get("VirtualMachine", 0)
datastores = by_type.get("Datastore", 0)
networks = by_type.get("Network", 0) + by_type.get("DistributedVirtualPortgroup", 0)
return {
"hosts": by_type.get("HostSystem", 0),
"vms": by_type.get("VirtualMachine", 0),
"datastores": by_type.get("Datastore", 0),
"hosts": hosts,
"vms": vms,
"datastores": datastores,
"networks": networks,
"datacenters": by_type.get("Datacenter", 0),
"clusters": by_type.get("ClusterComputeResource", 0),
"objects": sum(by_type.values()),
"by_type": by_type,
"profile_hint": default_profile_name(),
"profile_hint": infer_profile_hint(hosts=hosts, vms=vms, datastores=datastores),
}
+741 -144
View File
File diff suppressed because it is too large Load Diff
+39 -8
View File
@@ -229,12 +229,25 @@ async def ui_demo_state(request: Request) -> JSONResponse:
raise HTTPException(status_code=503, detail=str(error)) from error
async with pool.acquire() as connection:
pve = await simulation_state_summary(connection)
return JSONResponse({"vsphere": vsphere, "proxmox_stub": pve})
profile = vsphere.get("profile_hint") or "minimal"
loaded = profile in {"small", "large", "big", "demo-cluster", "demo", "enterprise"}
return JSONResponse(
{
"vsphere": vsphere,
"proxmox_stub": pve,
"loaded": loaded,
"label": f"{vsphere.get('hosts', 0)} hosts · {vsphere.get('vms', 0)} VMs",
"profile": profile,
}
)
profile = vsphere.get("profile_hint") or "minimal"
loaded = profile in {"small", "large", "big", "demo-cluster", "demo", "enterprise"}
return JSONResponse(
{
"vsphere": vsphere,
"loaded": vsphere.get("vms", 0) >= 100,
"loaded": loaded,
"label": f"{vsphere.get('hosts', 0)} hosts · {vsphere.get('vms', 0)} VMs",
"profile": profile,
}
)
@@ -245,17 +258,35 @@ async def ui_demo_load(request: Request) -> JSONResponse:
settings = _settings(request)
summary: dict = {}
profile_name = "demo-cluster"
size_raw = request.query_params.get("size") or request.query_params.get("profile")
if not size_raw:
try:
body = await request.json()
except Exception:
body = {}
if isinstance(body, dict):
size_raw = body.get("size") or body.get("profile")
profile_name = str(size_raw or "large").strip().lower() or "large"
if profile_name in {"demo-cluster", "demo", "enterprise"}:
profile_name = "big"
if profile_name not in {"small", "large", "big"}:
raise HTTPException(
status_code=400,
detail=f"unknown size {profile_name!r}; sizes: small, large, big",
)
if settings is not None and getattr(settings, "enable_pve_stub", False):
pool = _database_pool(request)
profile = build_profile("demo-cluster")
pve_name = "demo-cluster" if profile_name == "big" else profile_name
try:
profile = build_profile(pve_name)
except Exception:
profile = build_profile("demo-cluster")
async with pool.acquire() as connection:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
profile_name = profile.name
try:
vsphere = await seed_vsphere_inventory(
get_database(request), force=True, profile="demo-cluster"
get_database(request), force=True, profile=profile_name
)
except Exception as error:
raise HTTPException(status_code=503, detail=f"database is not ready: {error}") from error
@@ -271,7 +302,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
settings = _settings(request)
summary: dict = {}
profile_name = "small"
profile_name = "minimal"
try:
if settings is not None and getattr(settings, "enable_pve_stub", False):
pool = _database_pool(request)
@@ -280,7 +311,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
profile_name = profile.name
vsphere = await seed_vsphere_inventory(get_database(request), force=True, profile="small")
vsphere = await seed_vsphere_inventory(get_database(request), force=True, profile="minimal")
except HTTPException:
raise
except Exception as error:
+10
View File
@@ -7,3 +7,13 @@ Hot-swap (`POST /ui/api/contract/apply?major=N`) switches the active **catalog**
major for Web UI / evidence only; runtime still serves the full registered
surface (no HTTP 501 from version floor). Regenerate with
`make vsphere-bundles`.
## Console request parameters
Web UI Params / Request body for native vSphere routes come from the official
Automation OpenAPI (`vmware/vcf-api-specs``vcenter.yaml`), compacted into
`app/vsphere/rest/param_index.json`. Regenerate with:
```bash
make vsphere-param-index
```
+10
View File
@@ -7,3 +7,13 @@ Hot-swap (`POST /ui/api/contract/apply?major=N`) переключает акти
major только для Web UI / evidence; runtime по-прежнему обслуживает полную
зарегистрированную поверхность (без HTTP 501 из-за version floor).
Перегенерация: `make vsphere-bundles`.
## Параметры запросов в консоли
Params / Request body для нативных vSphere-маршрутов берутся из официального
Automation OpenAPI (`vmware/vcf-api-specs``vcenter.yaml`) и компактно
хранятся в `app/vsphere/rest/param_index.json`. Перегенерация:
```bash
make vsphere-param-index
```
+8 -6
View File
@@ -3,10 +3,9 @@
#
# Upstream listens on an internal-only port (simulator:8080). Host clients
# should hit https://localhost/ — not the internal app port.
upstream vmware_simulator {
server simulator:8080;
}
#
# Use a variable + Docker DNS resolver so nginx starts even if the simulator
# hostname is not yet resolvable (avoids crash-loop on recreate).
map $server_port $vmware_service {
default "simulator";
@@ -19,8 +18,10 @@ server {
server_name _;
resolver 127.0.0.11 valid=10s ipv6=off;
set $vmware_upstream simulator:8080;
location / {
proxy_pass http://vmware_simulator;
proxy_pass http://$vmware_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -43,9 +44,10 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
resolver 127.0.0.11 valid=10s ipv6=off;
set $vmware_upstream simulator:8080;
location / {
proxy_pass http://vmware_simulator;
proxy_pass http://$vmware_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+2 -2
View File
@@ -59,8 +59,8 @@ transfer sessions and HttpNfcLease rows live in `vsphere_transfer_sessions` /
`vsphere_nfc_leases` (`012`); PropertyCollector views/tokens and console
tickets persist in `vsphere_pc_state` / `vsphere_console_tickets` (`013`).
Seed profiles (`small` / `large` / `demo-cluster`) build a deterministic cluster —
default **large** is ~10 hosts / **1000 VMs**.
Seed profiles (`small` / `large` / `big`) build a deterministic cluster —
default **large** is 10 hosts / **1000 VMs** (`big` = 20 / 2000).
## AuthZ
+1 -1
View File
@@ -25,7 +25,7 @@ service. The typed settings model lives in [`app/config.py`](../app/config.py).
| Variable | Default | Meaning |
|---|---|---|
| `SEED_VSPHERE_PROFILE` | `large` | `small` \| `large` \| `demo-cluster` — see [Seed profiles](seed-profiles.md) |
| `SEED_VSPHERE_PROFILE` | `large` | `small` \| `large` \| `big` (`demo-cluster` aliases `big`) — see [Seed profiles](seed-profiles.md) |
| `SEED_VSPHERE_LARGE_HOSTS` | `10` | Host count for the `large` profile |
| `SEED_VSPHERE_LARGE_VMS` | `1000` | VM count for the `large` profile |
+1 -1
View File
@@ -147,7 +147,7 @@ curl -sk -H "vmware-api-session-id: $SID" \
Visit [https://localhost/](https://localhost/) for the interactive
console, endpoint catalog (vSphere majors 69), compatibility view, runtime
contract apply, and demo-cluster controls. See [Web UI](web-ui.md) for
light/dark theme screenshots and the full feature list.
screenshots and the full feature list.
## 8. Try a client library
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+2 -2
View File
@@ -60,8 +60,8 @@ transfer content library и строки HttpNfcLease — в `vsphere_transfer_s
`vsphere_nfc_leases` (`012`); views/tokens PropertyCollector и console tickets —
в `vsphere_pc_state` / `vsphere_console_tickets` (`013`).
Профили seed (`small` / `large` / `demo-cluster`) строят детерминированный
кластер — по умолчанию **large** это ~10 hosts / **1000 VMs**.
Профили seed (`small` / `large` / `big`) строят детерминированный
кластер — по умолчанию **large** это 10 hosts / **1000 VMs** (`big` = 20 / 2000).
## AuthZ
+1 -1
View File
@@ -25,7 +25,7 @@ Docker Compose инжектирует многие из них для серви
| Переменная | По умолчанию | Значение |
|---|---|---|
| `SEED_VSPHERE_PROFILE` | `large` | `small` \| `large` \| `demo-cluster` — см. [Профили seed](seed-profiles.md) |
| `SEED_VSPHERE_PROFILE` | `large` | `small` \| `large` \| `big` (`demo-cluster` = `big`) — см. [Профили seed](seed-profiles.md) |
| `SEED_VSPHERE_LARGE_HOSTS` | `10` | Число хостов для профиля `large` |
| `SEED_VSPHERE_LARGE_VMS` | `1000` | Число VM для профиля `large` |
+2 -2
View File
@@ -147,8 +147,8 @@ curl -sk -H "vmware-api-session-id: $SID" \
Откройте [https://localhost/](https://localhost/) — интерактивная
консоль, каталог эндпоинтов (vSphere majors 69), вид совместимости, apply
runtime-контракта и управление demo-cluster. Скриншоты светлой/тёмной темы и
полный список возможностей — [Web UI](web-ui.md).
runtime-контракта и управление demo-cluster. Скриншоты и полный список
возможностей — [Web UI](web-ui.md).
## 8. Попробуйте клиентскую библиотеку
+15 -61
View File
@@ -2,75 +2,29 @@
# Профили seed
Seed **атомарно** заменяет инвентарь vSphere, используя детерминированные
MOID, чтобы лаборатории были воспроизводимыми. Определения находятся в
[`app/vsphere/profiles.py`](../../app/vsphere/profiles.py).
Seed атомарно заменяет инвентарь vSphere с детерминированными MOID.
Определения: [`app/vsphere/profiles.py`](../../app/vsphere/profiles.py).
```bash
make seed # по умолчанию: large (10 хостов / 1000 ВМ)
make seed # по умолчанию: large (10 hosts / 1000 VMs)
VSPHERE_PROFILE=small make seed
VSPHERE_PROFILE=big make seed
VSPHERE_PROFILE=minimal make seed
```
## Профили
| Профиль | Содержимое |
|---|---|
| `small` | 3 хоста ESXi, 2 datastore, 2 сети, один datacenter/cluster/resource-pool и пять именованных ВМ: `web-01`, `web-02`, `db-01`, `app-01`, `jumpbox` (смешанные состояния питания). Используется unit/integration-тестами. |
| `large` (по умолчанию) | Настраиваемое число хостов/ВМ (`SEED_VSPHERE_LARGE_HOSTS` по умолчанию 10, `SEED_VSPHERE_LARGE_VMS` по умолчанию 1000), 4 datastore, 4 сети/portgroup, `VmwareDistributedVirtualSwitch`, папки ВМ production/staging/templates. Первые пять ВМ совпадают по именам с `small` для стабильности кулинарных книг; остальные генерируются (префиксы ролей `web-`, `app-`, `db-`, `cache-`, `batch-`, `jump-`, `ci-`, `mon-`, `log-`, `ml-`). |
| `demo-cluster` | `large` с 20 хостами / 1000 ВМ — набор данных в форме предприятия для демо UI. |
| Профиль | Hosts | VMs | Datastores | Networks | Folders† | Content extras |
|---|---:|---:|---:|---:|---:|---:|
| `minimal` | 3 | 5 | 1 | 1 | 8 | ×1 |
| `small` | 3 | 50 | 2 | 2 | 8 | ×1 |
| `large` (default) | 10 | 1000 | 4 | 4 | 10 | ×2 |
| `big` | 20 | 2000 | 8 | 8 | 14 | ×4 |
Каждый профиль также загружает четыре лабораторные учётные записи, права,
привязанные к ролям (см. [Авторизация](domains/authz.md)), и — там, где
существуют таблицы платформы — стартовую content library, категории/теги
тегирования и метаданные файлов datastore (`seed_platform_extras`).
† Spine + team folders на крупных тирах.
## Примеры
`demo-cluster` = алиас `big`. Reset / unload → `minimal`.
```bash
make seed # large, 10 хостов / 1000 ВМ
VSPHERE_PROFILE=small make seed
VSPHERE_PROFILE=demo-cluster make seed
VSPHERE_PROFILE=large VSPHERE_HOSTS=20 VSPHERE_VMS=5000 make seed
```
## Web UI
Либо запустите CLI seed напрямую с базовыми переменными окружения (например,
из скрипта без `make` или на шаге CI):
```bash
SEED_VSPHERE_PROFILE=small \
docker compose run --rm --entrypoint python simulator -m app.simulation.seed_cli
```
## Форма топологии
Каждый профиль строит один и тот же скелет (папка `Datacenters`
`Datacenter` → подпапки host/vm/datastore/network → один
`ClusterComputeResource` + `ResourcePool`), затем масштабирует хосты,
datastore, portgroup и ВМ. MOID ВМ имеют вид `vm-{100+n}`; MOID хостов —
`host-{10+n}`; каждая ВМ несёт одинаковую форму оборудования, используемую
как REST (`hardware/*`), так и SOAP (`VirtualMachineConfigInfo`) ответами —
NIC, диски, CD-ROM, порядок загрузки и синтетический guest IP/файловая
система.
## Демо-кластер через UI
Интерактивная консоль может загружать демо-набор данных и делать reseed по
запросу:
- `POST /ui/api/demo/load` — загружает `demo-cluster`
- `POST /ui/api/demo/unload` — очищает состояние, созданное через API, затем
загружает `small`
- `GET /ui/api/demo/state`
- `POST /ui/api/vsphere/seed?profile=small|large|demo-cluster` — reseed
любого профиля
Эти вспомогательные эндпоинты UI ориентированы на разработку и сегодня не
имеют отдельной аутентификации. Считайте их только лабораторными органами
управления.
## Reseed в сравнении с состоянием клиентов
Terraform, Pulumi и Ansible могут по-прежнему хранить состояние ресурсов
после reseed (MOID и имена ВМ могут измениться). Выполните refresh или
destroy/recreate внешнего состояния после замены инвентаря PostgreSQL. См.
[Эксплуатация](operations.md) и [Клиенты](clients.md).
Панель **DATA** — три карточки (Small / Large / Big) и **Reset to minimal**.
+47 -6
View File
@@ -2,19 +2,60 @@
# Web UI
Откройте [https://localhost/](https://localhost/) после `make up`
(gateway).
Внутренний порт симулятора — `8080`; лабораторный UI также доступен на этом хосте.
Откройте [https://localhost/](https://localhost/) после `make up` (gateway) или
HTTPS URL, который печатает `make up-local`. Внутренний порт симулятора —
`8080`; лабораторный UI также доступен на этом хосте.
UI — это лабораторная консоль для симулятора **vSphere**, а не замена
vSphere Client. Она поддерживает светлую и тёмную темы, мажоры каталога
**69** (уровни vSphere 7–8.0U2), редактирование запроса/ответа, историю и
применение runtime-контракта.
## Скриншоты
### Главная консоль
![Главная консоль Web UI](../images/web-ui-main.png)
### Каталог API (мажоры 6–9)
![Каталог API с выбранным vSphere 8.0 U2](../images/web-ui-api-catalog.png)
### Браузер endpoint'ов
![Дерево endpoint'ов и пример ответа GET](../images/web-ui-endpoints.png)
### Запрос / ответ
![GET /api/vcenter/vm с фильтрами и ответом 200](../images/web-ui-request.png)
![Редактор параметров запроса](../images/web-ui-request-params.png)
### История
![Панель истории запросов](../images/web-ui-history.png)
### Аутентификация
![Вход в сессию (лабораторные учётные данные)](../images/web-ui-authentication.png)
### Данные / seed
![Demo cluster и управление seed](../images/web-ui-data.png)
### Окружение
![Сводка инвентаря окружения](../images/web-ui-environment.png)
### Справка — совместимость
![Диагностика совместимости (1092 / 1092)](../images/web-ui-help-compatibility.png)
## Возможности
- Дерево endpoint'ов и селектор метода, управляемые выбранным мажором каталога
- Параметры и примеры payload, производные от контракта
- Панель Query parameters (левая колонка) с live-превью query string для GET-фильтров
- Редактор запроса, просмотрщик ответа и история
- Вход в сессию через `POST /api/session` (Basic) → заголовок/cookie
`vmware-api-session-id`
@@ -22,7 +63,7 @@ vSphere Client. Она поддерживает светлую и тёмную
- Предпросмотр запросов в виде curl
- Индикатор покрытия для реализованного реестра REST
- Hot-swap **Apply as runtime** для активного уровня мажора
- Загрузка demo / seed (профили large / demo-cluster)
- Загрузка demo / seed (профили `small` / `large` / `big` в DATA)
- Компактная консоль на `/console.html`
- Ссылка на OpenAPI по адресу `/docs`
@@ -47,8 +88,8 @@ vSphere Client. Она поддерживает светлую и тёмную
| GET | `/ui/api/compatibility?major=N` | Payload покрытия |
| POST | `/ui/api/contract/apply?major=N` | Hot-swap runtime-контракта |
| GET | `/ui/api/demo/state` | Состояние демо-набора данных |
| POST | `/ui/api/demo/load` | Загрузить `demo-cluster` |
| POST | `/ui/api/vsphere/seed?profile=…` | Reseed `small` / `large` / `demo-cluster` |
| POST | `/ui/api/demo/load` | Загрузить `big` (20 hosts / 2000 VMs) |
| POST | `/ui/api/vsphere/seed?profile=…` | Reseed `small` / `large` / `big` |
## Workflow работы с версиями
+24 -31
View File
@@ -9,28 +9,32 @@ so labs are reproducible. Definitions live in
```bash
make seed # default: large (10 hosts / 1000 VMs)
VSPHERE_PROFILE=small make seed
VSPHERE_PROFILE=big make seed
```
## Profiles
| Profile | Contents |
|---|---|
| `small` | 3 ESXi hosts, 2 datastores, 2 networks, one datacenter/cluster/resource-pool, and five named VMs: `web-01`, `web-02`, `db-01`, `app-01`, `jumpbox` (mixed power states). Used by unit/integration tests. |
| `large` (default) | Configurable hosts/VMs (`SEED_VSPHERE_LARGE_HOSTS` default 10, `SEED_VSPHERE_LARGE_VMS` default 1000), 4 datastores, 4 networks/portgroups, a `VmwareDistributedVirtualSwitch`, production/staging/templates VM folders. The first five VMs match the `small` names for cookbook stability; the rest are generated (`web-`, `app-`, `db-`, `cache-`, `batch-`, `jump-`, `ci-`, `mon-`, `log-`, `ml-` role prefixes). |
| `demo-cluster` | `large` with 20 hosts / 1000 VMs — an enterprise-shaped dataset for UI demos. |
| Profile | Hosts | VMs | Datastores | Networks | Folders† | Content extras |
|---|---:|---:|---:|---:|---:|---:|
| `minimal` | 3 | 5 | 1 | 1 | 8 | ×1 |
| `small` | 3 | 50 | 2 | 2 | 8 | ×1 |
| `large` (default) | 10 | 1000 | 4 | 4 | 10 | ×2 |
| `big` | 20 | 2000 | 8 | 8 | 14 | ×4 |
Every profile also seeds the four lab credentials, role-scoped permissions
(see [Authorization](domains/authz.md)), and — where the platform tables
exist — a starter content library, tag categories/tags, and datastore file
metadata (`seed_platform_extras`).
† Spine folders (8) plus scaled team folders for larger tiers.
`demo-cluster` remains an alias of `big`. Reset / unload loads `minimal`.
## Examples
```bash
make seed # large, 10 hosts / 1000 VMs
VSPHERE_PROFILE=small make seed
VSPHERE_PROFILE=demo-cluster make seed
VSPHERE_PROFILE=big make seed
VSPHERE_PROFILE=large VSPHERE_HOSTS=20 VSPHERE_VMS=5000 make seed
# 100% seed ↔ live inventory dump
VSPHERE_PROFILE=big make vsphere-seed-dump
```
Or run the seed CLI directly with the underlying environment variables (for
@@ -43,28 +47,17 @@ SEED_VSPHERE_PROFILE=small \
## Topology shape
Every profile builds the same skeleton (`Datacenters` folder`Datacenter`
host/vm/datastore/network sub-folders → one `ClusterComputeResource` +
`ResourcePool`), then scales hosts, datastores, portgroups, and VMs. VM MOIDs
are `vm-{100+n}`; host MOIDs are `host-{10+n}`; each VM carries the same
hardware shape used by both REST (`hardware/*`) and SOAP (`VirtualMachineConfigInfo`)
responses — NICs, disks, CD-ROM, boot order, and a synthetic guest IP/filesystem.
Every profile builds the same spine (`Datacenters``Datacenter` host/vm/
datastore/network folders → one `ClusterComputeResource` + `ResourcePool`),
then scales hosts, datastores, port groups, and VMs. VM MOIDs are `vm-{100+n}`;
host MOIDs are `host-{10+n}`.
## Demo cluster via UI
## Web UI
The interactive console can load the demo dataset and reseed on demand:
The **DATA** panel exposes three load buttons matching these tiers. Each call
is `POST /ui/api/vsphere/seed?profile=small|large|big` (atomic reseed).
- `POST /ui/api/demo/load` — loads `demo-cluster`
- `POST /ui/api/demo/unload` — wipes API-created state, then loads `small`
- `GET /ui/api/demo/state`
- `POST /ui/api/vsphere/seed?profile=small|large|demo-cluster` — reseed any profile
Legacy endpoints:
These UI helper endpoints are development-oriented and are not separately
authenticated today. Treat them as lab controls only.
## Reseed vs client state
Terraform, Pulumi, and Ansible may still hold resource state after a reseed
(VM MOIDs and names can change). Refresh or destroy/recreate external state
after replacing the PostgreSQL inventory. See [Operations](operations.md) and
[Clients](clients.md).
- `POST /ui/api/demo/load``big`
- `POST /ui/api/demo/unload``small`
+47 -5
View File
@@ -2,24 +2,66 @@
# Web UI
Open [https://localhost/](https://localhost/) after `make up` (gateway).
Internal simulator port is `8080`; the lab UI is also on that host.
Open [https://localhost/](https://localhost/) after `make up` (gateway), or the
HTTPS URL printed by `make up-local`. Internal simulator port is `8080`; the lab
UI is also on that host.
The UI is a laboratory console for the **vSphere** simulator — not a vSphere Client
replacement. It supports light and dark themes, catalog majors **69** (vSphere 78.0U2
floors), request/response editing, history, and runtime contract apply.
## Screenshots
### Main console
![Web UI main console](images/web-ui-main.png)
### API catalog (majors 69)
![API catalog with vSphere 8.0 U2 selected](images/web-ui-api-catalog.png)
### Endpoint browser
![Endpoint tree and sample GET response](images/web-ui-endpoints.png)
### Request / response
![GET /api/vcenter/vm with filters and 200 response](images/web-ui-request.png)
![Request parameters editor](images/web-ui-request-params.png)
### History
![Request history panel](images/web-ui-history.png)
### Authentication
![Session sign-in (lab credentials)](images/web-ui-authentication.png)
### Data / seed
![Demo cluster and seed controls](images/web-ui-data.png)
### Environment
![Environment inventory summary](images/web-ui-environment.png)
### Help — compatibility
![Compatibility diagnostics (1092 / 1092)](images/web-ui-help-compatibility.png)
## Features
- Endpoint tree and method selector driven by the selected catalog major
- Contract-derived parameters and example payloads
- Query parameters pane (left column) with live query-string preview for GET filters
- Request editor, response viewer, and history
- Session login via `POST /api/session` (Basic) → `vmware-api-session-id` header/cookie
- Environment summary (runtime version, hosts, VMs, clusters, datastores, networks)
- Curl / request previews
- Coverage meter for the implemented REST registry
- **Apply as runtime** hot-swap for the active major floor
- Demo / seed load (large / demo-cluster profiles)
- Demo / seed load (`small` / `large` / `big` tiers in DATA)
- Compact console at `/console.html`
- Link to OpenAPI at `/docs`
@@ -44,8 +86,8 @@ After sign-in, Send attaches `vmware-api-session-id` automatically.
| GET | `/ui/api/compatibility?major=N` | Coverage payload |
| POST | `/ui/api/contract/apply?major=N` | Hot-swap runtime contract |
| GET | `/ui/api/demo/state` | Demo dataset state |
| POST | `/ui/api/demo/load` | Load `demo-cluster` |
| POST | `/ui/api/vsphere/seed?profile=…` | Reseed `small` / `large` / `demo-cluster` |
| POST | `/ui/api/demo/load` | Load `big` (20 hosts / 2000 VMs) |
| POST | `/ui/api/vsphere/seed?profile=…` | Reseed `small` / `large` / `big` |
## Version workflow
+39 -24
View File
@@ -2,37 +2,50 @@
Hybrid suite under `pulumi-tests/`:
1. Official [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) (SOAP/VIM via govmomi)
2. Full REST `IMPLEMENTED` × majors **69** matrix (deep + stub response checks)
1. **Layer A (required for “100%”)** — HTTP contract matrix: all `IMPLEMENTED`
REST `verb+path` × catalog majors **69**, plus synthetic **HEAD** on every GET
2. **Layer B (smoke / lifecycle)** — official
[`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) programs
3. Deep REST CRUD (session / folder / tag / content library / VM)
4. All SOAP WSDL ops advertised on `/sdk/vimService.wsdl`
4. SOAP WSDL ops already advertised on `/sdk/vimService.wsdl` (not a new SOAP universe)
`pulumi-vsphere` alone cannot hit ~1092 REST routes — the HTTP matrix is required for full-surface confidence.
`pulumi-vsphere` alone cannot hit ~1092 REST routes — **100% means the HTTP
matrix** (`coverage probed/declared`, `critical=0`), not provider resource count.
## Pass rules
## Pass rules (Layer A)
| Check | Rule |
|-------|------|
| Declared | `methods_for_major(M)` minus session DELETE, plus `HEAD` per GET |
| Probed | Exactly one authd probe per declared route (`probed == declared`) |
| Critical | `0` — no 5xx/501; no `"stub": true` / empty body on inventory-critical GETs (major 9) |
| Coverage line | `{probed - critical}/{declared}` (aggregate + per major) |
| Verb histogram | Includes `HEAD` |
| Stubs | Spec-shaped durable JSON OK; reported as `stub`, not durable CRUD |
| Auth | Fresh `vmware-api-session-id` session for each major pass |
## Pass rules (Layer B + extras)
| Class | Pass rule |
|-------|-----------|
| REST GET (inventory-critical / deep) | 2xx, body non-empty, not `"stub": true` on major 9 critical paths |
| REST stubs (universe) | no 5xx/501; response present; reported as `stub` (not claimed durable CRUD) |
| pulumi-vsphere | Existing cases + nonempty exports (lifecycle smoke, not full API) |
| REST deep CRUD | create → GET nonempty → update (where supported) → delete → GET missing |
| SOAP WSDL ops (~45) | POST `/sdk` without 5xx; Create/Power/Clone/Destroy checked via inventory |
| pulumi-vsphere | existing cases + nonempty exports |
| SOAP WSDL ops | POST `/sdk` without 5xx; Create/Power/Clone/Destroy checked via inventory |
## What runs
| Case | Layer | Notes |
|------|-------|-------|
| `PU-INV` | pulumi-vsphere | Inventory data sources |
| `PU-FOLDER` | pulumi-vsphere | Folder create/destroy |
| `PU-VM` | pulumi-vsphere | VirtualMachine create/destroy |
| `PU-TAG` | pulumi-vsphere | TagCategory + Tag |
| `PU-REST` | REST matrix | `IMPLEMENTED` × majors 69 (smoke: major 9 only) |
| `PU-CRUD` | REST CRUD | Session, folder, tagging, content library, VM |
| `PU-SOAP` | SOAP | All WSDL ops |
| `PU-INV` | B · pulumi-vsphere | Inventory data sources |
| `PU-FOLDER` | B · pulumi-vsphere | Folder create/destroy |
| `PU-VM` | B · pulumi-vsphere | VirtualMachine create/destroy |
| `PU-TAG` | B · pulumi-vsphere | TagCategory + Tag |
| `PU-REST` | **A · HTTP matrix** | `IMPLEMENTED` × majors 69 + HEAD (smoke: major 9) |
| `PU-CRUD` | Deep REST | Session, folder, tagging, content library, VM |
| `PU-SOAP` | SOAP | Existing WSDL ops only |
Artifacts: HTML + JSON + JUnit under the `lab-reports` volume. JSON includes
`rest.total` / `rest.failed`, `crud.failed`, `soap.failed`.
Artifacts: HTML + JSON + JUnit under the `lab-reports` volume. Look for
`rest.coverage_line`, `rest.critical`, `rest.by_verb` (incl. HEAD).
## Quick start
@@ -40,7 +53,7 @@ From the **repo root**:
```bash
make pulumi-tests # full hybrid suite
make pulumi-tests-smoke # PU-INV + one-major REST smoke
make pulumi-tests-smoke # PU-INV + Layer A major-9 smoke
```
Or from this directory:
@@ -51,7 +64,9 @@ make up
make test-pulumi # or: make test-pulumi-smoke
```
Gateway (in-compose): `https://api-gateway` (host map `127.0.0.1:18443`).
Gateway (in-compose): `https://api-gateway` (runner → service name).
Optional host map: `127.0.0.1:28443` (does **not** replace main lab
`https://localhost` / `:18443` — stop this stack with `make down` when done).
Seed profile: `small``Datacenter` / `Cluster` / `datastore1` / `VM Network` /
`web-01` / `esxi01.lab.local` / `/Datacenter/vm/production`.
@@ -59,8 +74,8 @@ Seed profile: `small` → `Datacenter` / `Cluster` / `datastore1` / `VM Network`
| Target | Meaning |
|--------|---------|
| `make test-pulumi` / `pulumi-tests` | Full hybrid: pulumi-vsphere + REST×69 + CRUD + SOAP |
| `make test-pulumi-smoke` | `PU-INV` + REST major-9 smoke (no VM/tags/CRUD/SOAP) |
| `make test-pulumi` / `pulumi-tests` | Full hybrid: Layer A ×69 + Layer B + CRUD + SOAP |
| `make test-pulumi-smoke` | `PU-INV` + Layer A major-9 smoke (no VM/tags/CRUD/SOAP) |
| `make up` / `down` / `seed` | Lab stack lifecycle |
## Layout
@@ -68,9 +83,9 @@ Seed profile: `small` → `Datacenter` / `Cluster` / `datastore1` / `VM Network`
```
pulumi-tests/
run_suite.py # Automation API + REST/SOAP probes
report_html.py
report_html.py # coverage line + verb histogram
lib/assert_nonempty.py
lib/rest_matrix.py # IMPLEMENTED × majors
lib/rest_matrix.py # Layer A: IMPLEMENTED × majors + HEAD
lib/rest_crud.py # deep create/read/update/delete
lib/soap_ops.py # WSDL SOAP ops
programs/inventory/
+23 -58
View File
@@ -2,77 +2,42 @@
Гибрид под `pulumi-tests/`:
1. Официальный [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) (SOAP/VIM через govmomi)
2. Полная REST-матрица `IMPLEMENTED` × majors **69** (deep + stub)
1. **Layer A (обязателен для «100%»)** — HTTP contract matrix: все `IMPLEMENTED`
REST `verb+path` × majors **69**, плюс синтетический **HEAD** на каждый GET
2. **Layer B (smoke / lifecycle)** — официальный
[`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/)
3. Deep REST CRUD (session / folder / tag / content library / VM)
4. Все SOAP-операции из `/sdk/vimService.wsdl`
4. SOAP-операции из `/sdk/vimService.wsdl` (без нового SOAP universe)
Одним `pulumi-vsphere` закрыть ~1092 REST `verb×path` нельзя — HTTP-матрица обязательна для полной уверенности.
Одним `pulumi-vsphere` закрыть ~1092 REST `verb×path` нельзя — **100% = HTTP-матрица**
(`coverage probed/declared`, `critical=0`), а не число provider-ресурсов.
## Критерии pass
## Критерии pass (Layer A)
| Класс | Правило |
|-------|---------|
| REST GET (inventory / deep) | 2xx, непустое тело, без `"stub": true` на critical-путях major 9 |
| REST stubs (universe) | нет 5xx/501; ответ есть; в отчёте как `stub` (не durable CRUD) |
| REST deep CRUD | create → GET nonempty → update (если есть) → delete → GET missing |
| SOAP WSDL (~45) | POST `/sdk` без 5xx; Create/Power/Clone/Destroy — проверка inventory |
| pulumi-vsphere | существующие кейсы + nonempty export’ы |
| Проверка | Правило |
|----------|---------|
| Declared | `methods_for_major(M)` минус session DELETE, плюс `HEAD` на каждый GET |
| Probed | Ровно один auth’d probe на каждый declared (`probed == declared`) |
| Critical | `0` — нет 5xx/501; нет `"stub": true` / пустого тела на inventory-critical GET (major 9) |
| Coverage | `{probed - critical}/{declared}` (aggregate + per major) |
| Verb histogram | Включая `HEAD` |
## Что запускается
| Кейс | Слой | Заметки |
|------|------|---------|
| `PU-INV` | pulumi-vsphere | Inventory data sources |
| `PU-FOLDER` | pulumi-vsphere | Folder create/destroy |
| `PU-VM` | pulumi-vsphere | VirtualMachine create/destroy |
| `PU-TAG` | pulumi-vsphere | TagCategory + Tag |
| `PU-REST` | REST matrix | `IMPLEMENTED` × majors 69 (smoke: только major 9) |
| `PU-CRUD` | REST CRUD | Session, folder, tagging, content library, VM |
| `PU-SOAP` | SOAP | Все WSDL ops |
Артефакты: HTML + JSON + JUnit в volume `lab-reports`. В JSON:
`rest.total` / `rest.failed`, `crud.failed`, `soap.failed`.
| `PU-INV` `PU-TAG` | B · pulumi-vsphere | Lifecycle / inventory smoke |
| `PU-REST` | **A · HTTP matrix** | `IMPLEMENTED` × 69 + HEAD (smoke: major 9) |
| `PU-CRUD` / `PU-SOAP` | extras | Deep CRUD + существующий SOAP |
## Быстрый старт
Из **корня репозитория**:
```bash
make pulumi-tests # полный гибрид
make pulumi-tests-smoke # PU-INV + REST smoke на одном major
make pulumi-tests-smoke # PU-INV + Layer A major-9
```
Или из этой директории:
```bash
cd pulumi-tests
make up
make test-pulumi # или: make test-pulumi-smoke
```
Шлюз (в compose): `https://api-gateway` (с хоста `127.0.0.1:18443`).
Seed-профиль: `small``Datacenter` / `Cluster` / `datastore1` / `VM Network` /
`web-01` / `esxi01.lab.local` / `/Datacenter/vm/production`.
## Цели Make
| Цель | Смысл |
|------|--------|
| `make test-pulumi` / `pulumi-tests` | Полный гибрид: pulumi-vsphere + REST×69 + CRUD + SOAP |
| `make test-pulumi-smoke` | `PU-INV` + REST major-9 smoke (без VM/tags/CRUD/SOAP) |
| `make up` / `down` / `seed` | Жизненный цикл lab-стека |
## Структура
```
pulumi-tests/
run_suite.py
report_html.py
lib/rest_matrix.py
lib/rest_crud.py
lib/soap_ops.py
programs/...
```
`PYTHONPATH` монтирует `/workspace` для импорта `app.vsphere.*`.
Шлюз (в compose): `https://api-gateway`. Хост-алиас: `127.0.0.1:28443`
(не занимает `https://localhost` / `:18443` основного lab — после тестов
`make down`).
Seed: `small`. Артефакты: `rest.coverage_line`, `rest.critical`, `rest.by_verb`.
+3 -1
View File
@@ -146,7 +146,9 @@ services:
- ../docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
- ../docker/tls/server.key:/etc/nginx/tls/server.key:ro
ports:
- "127.0.0.1:18443:443"
# Host-only alias; keep off 443/18443 so this stack can coexist with the
# main repo compose (https://localhost) without stealing the lab gateway.
- "127.0.0.1:28443:443"
pulumi-runner:
build:
+168 -131
View File
@@ -1,7 +1,12 @@
"""Full REST verb×path×majors matrix for pulumi-tests (hybrid suite).
"""Full REST verb×path×majors matrix for pulumi-tests (Layer A HTTP contract).
Reuses path substitution / session patterns from scripts/vsphere_full_matrix_probe.py.
Pass rules match that probe: no 5xx/501; inventory GETs nonempty + non-stub on major 9.
Probes ``methods_for_major(M)`` for majors 69 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
@@ -18,7 +23,7 @@ from collections import Counter
from typing import Any
from urllib.parse import urlencode
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major, methods_for_major
from app.vsphere.contracts.matrix import VERSIONS, methods_for_major
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED
_PATH_SUBS = {
@@ -30,6 +35,7 @@ _PATH_SUBS = {
"{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",
@@ -75,20 +81,47 @@ _PATH_SUBS = {
_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")
@@ -127,7 +160,6 @@ def request(
headers: dict[str, str],
data: bytes | None = None,
) -> tuple[int, str]:
# Paths may already include query strings from _payload_for.
if "?" in path:
base_path, query = path.split("?", 1)
url = f"{_base()}{concrete_path(base_path)}?{query}"
@@ -152,6 +184,16 @@ def login() -> str:
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
@@ -165,7 +207,14 @@ def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
return f"{path}?action=enter", b"{}"
if path.endswith("/folder/{folder}") and verb == "POST":
return f"{path}?action=rename", json.dumps({"name": "folder-renamed-probe"}).encode()
# 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]] = {
@@ -175,10 +224,15 @@ def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
"cpu_count": 1,
"memory_size_MiB": 512,
},
"/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}"},
"/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}"},
# 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-22"},
"/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}",
@@ -263,14 +317,25 @@ def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
def _classify(verb: str, path: str) -> str:
status = CORE_IMPLEMENTED.get((verb, path)) or IMPLEMENTED.get((verb, path))
if (verb, path) in CORE_IMPLEMENTED:
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,
@@ -284,82 +349,55 @@ def _record_result(
) -> 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
if major == 9 and verb == "GET" and body:
# 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":
buckets["stub_marker"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
"expected": "non-stub JSON",
}
)
elif path in _INVENTORY_CRITICAL:
try:
parsed = json.loads(body)
except json.JSONDecodeError:
parsed = None
empty = parsed in ([], {}, None, "")
if empty:
buckets["empty_inventory"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
"expected": "non-empty seeded data",
}
)
elif code in _ACCEPT_CLIENT:
_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
elif code == 501:
buckets["unexpected_501"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
elif code >= 500:
buckets["server_5xx"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
else:
buckets[f"other_{code}"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
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]:
@@ -369,12 +407,11 @@ def probe_major(major: int, session: str) -> dict[str, Any]:
"Accept": "application/json",
}
applied = apply_major(major, headers)
active = methods_for_major(major)
verb_order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4}
entries = sorted(
catalog_entries_for_major(major),
key=lambda e: (verb_order.get(e["verb"], 9), e["path"]),
)
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()
@@ -382,30 +419,35 @@ def probe_major(major: int, session: str) -> dict[str, Any]:
probed = 0
by_verb: Counter[str] = Counter()
for entry in entries:
verb = entry["verb"]
path = entry["path"]
for verb, path in routes:
by_verb[verb] += 1
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
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}",
}:
if path.endswith("{vm}"):
url_path = path.replace("{vm}", "vm-missing-matrix")
elif path.endswith("{datacenter}"):
url_path = path.replace("{datacenter}", "dc-missing")
elif path.endswith("{cluster}"):
url_path = path.replace("{cluster}", "cluster-missing")
elif path.endswith("{folder}"):
url_path = path.replace("{folder}", "folder-missing")
else:
url_path = path.replace("{resource_pool}", "rp-missing")
# 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":
@@ -424,44 +466,28 @@ def probe_major(major: int, session: str) -> dict[str, Any]:
deep_stub=deep_stub,
)
above_floor = 0
for (verb, path), _status in sorted(IMPLEMENTED.items()):
if (verb, path) in active:
continue
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
url_path, data = _payload_for(verb, path)
code, body = request(verb, url_path, headers=headers, data=data)
above_floor += 1
probed += 1
by_verb[verb] += 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"),
"method_count": len(entries),
"declared": declared,
"method_count": len(methods_for_major(major)),
"by_verb": dict(by_verb),
"probed": probed,
"above_floor_checked": above_floor,
"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. Returns summary suitable for suite JSON/HTML."""
"""Probe IMPLEMENTED × majors (+ HEAD). Returns summary for suite JSON/HTML."""
if majors is None:
majors = [6, 7, 8, 9]
@@ -473,25 +499,36 @@ def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
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"})
total = sum(r["probed"] for r in reports)
failed = len(all_failures)
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),
"total": total,
"failed": failed,
"declared": declared_total,
"probed": probed_total,
"total": probed_total,
"critical": critical_total,
"failed": critical_total,
"failures": all_failures[:120],
"ok": failed == 0,
"coverage_line": coverage_line,
"probed_eq_declared": probed_total == declared_total,
"ok": ok,
}
@@ -0,0 +1 @@
encryptionsalt: v1:plwEBConp18=:v1:ZsvOhY9oF/fv3+9T:mfrqPTkwm7ehCrIB2RyY7nM1YyPelg==
@@ -0,0 +1 @@
encryptionsalt: v1:YdNhRHu/0Mg=:v1:3cfGg0xOH+zbxrg5:cMV7UqHRgphaG0bTa39P+MC2eZi+8w==
@@ -0,0 +1 @@
encryptionsalt: v1:xkpCARg0inY=:v1:+gu7XsBoXJf1x1R8:UewZrU1hON5hMYxbmVd24oXzxQDZXA==
+3 -1
View File
@@ -56,8 +56,10 @@ host = vsphere.get_host_output(
opts=invoke_opts,
)
folder = vsphere.get_folder_output(path=folder_path, opts=invoke_opts)
# Prefer cluster-scoped path so a polluted lab with multiple "Resources" pools
# (e.g. leftover probe datacenters) still resolves uniquely.
pool = vsphere.get_resource_pool_output(
name=pool_name,
name=f"{cluster_name}/{pool_name}",
datacenter_id=dc.id,
opts=invoke_opts,
)
+16 -9
View File
@@ -17,6 +17,8 @@ def _section_rest(summary: dict[str, Any]) -> str:
majors = rest.get("majors") or []
by_verb = rest.get("by_verb") or {}
failures = rest.get("failures") or []
coverage = rest.get("coverage_line") or f"{(rest.get('probed') or 0) - (rest.get('critical') or 0)}/{rest.get('declared') or rest.get('total') or 0}"
critical = rest.get("critical", rest.get("failed", 0))
major_rows = []
for m in majors:
@@ -26,8 +28,10 @@ def _section_rest(summary: dict[str, Any]) -> str:
"<tr>"
f"<td>{_esc(m.get('major'))}</td>"
f"<td>{_esc(m.get('version'))}</td>"
f"<td>{_esc(m.get('declared'))}</td>"
f"<td>{_esc(m.get('probed'))}</td>"
f"<td>{_esc(m.get('failed'))}</td>"
f"<td>{_esc(m.get('coverage_line') or '')}</td>"
f"<td>{_esc(m.get('critical', m.get('failed')))}</td>"
f"<td>{_esc(buckets.get('success_2xx', 0))}</td>"
f"<td>{_esc(buckets.get('client_4xx', 0))}</td>"
f"<td>{_esc(buckets.get('server_5xx', 0))}</td>"
@@ -50,22 +54,25 @@ def _section_rest(summary: dict[str, Any]) -> str:
)
return f"""
<h2>REST matrix</h2>
<p class="meta">total={_esc(rest.get("total"))} · failed={_esc(rest.get("failed"))}
· verbs: {verb_bits or ""}</p>
<h2>REST HTTP contract matrix (Layer A)</h2>
<p class="meta"><strong>coverage {_esc(coverage)}</strong>
· critical={_esc(critical)}
· probed={_esc(rest.get("probed") or rest.get("total"))}
· declared={_esc(rest.get("declared"))}
· verbs: {verb_bits or ""}
<br/>100% = HTTP matrix (IMPLEMENTED × majors + HEAD), not pulumi-vsphere resource count.</p>
<table>
<thead><tr><th>Major</th><th>Version</th><th>Probed</th><th>Failed</th>
<th>2xx</th><th>4xx</th><th>5xx</th><th>deep/stub</th></tr></thead>
<tbody>{"".join(major_rows) or '<tr><td colspan="8">—</td></tr>'}</tbody>
<thead><tr><th>Major</th><th>Version</th><th>Declared</th><th>Probed</th><th>Coverage</th>
<th>Critical</th><th>2xx</th><th>4xx</th><th>5xx</th><th>deep/stub</th></tr></thead>
<tbody>{"".join(major_rows) or '<tr><td colspan="10">—</td></tr>'}</tbody>
</table>
<h3>REST failures (sample)</h3>
<h3>REST critical failures (sample)</h3>
<table>
<thead><tr><th>Major</th><th>Verb</th><th>Path</th><th>Kind</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>{"".join(fail_rows) or '<tr><td colspan="6">none</td></tr>'}</tbody>
</table>
"""
def _section_crud(summary: dict[str, Any]) -> str:
crud = summary.get("crud") or {}
flows = crud.get("flows") or []
+17 -5
View File
@@ -188,18 +188,24 @@ def _run_rest_matrix() -> dict:
}
status = "passed" if summary.get("ok") else "failed"
err = ""
coverage = summary.get("coverage_line") or "?"
if not summary.get("ok"):
sample = summary.get("failures") or []
err = f"REST matrix failures={summary.get('failed')} total={summary.get('total')}; "
err = (
f"REST matrix coverage={coverage} critical={summary.get('critical')} "
f"probed={summary.get('probed')} declared={summary.get('declared')}; "
)
err += "; ".join(f"{f.get('verb')} {f.get('path')}{f.get('status')}" for f in sample[:8])
return {
"id": "PU-REST",
"title": f"REST IMPLEMENTED×majors {majors} (deep+stub response check)",
"title": f"REST HTTP matrix majors={majors} (IMPLEMENTED + HEAD; Layer A)",
"status": status,
"error": err,
"outputs": {
"total": summary.get("total"),
"failed": summary.get("failed"),
"coverage": coverage,
"critical": summary.get("critical"),
"probed": summary.get("probed"),
"declared": summary.get("declared"),
"by_verb": summary.get("by_verb"),
"majors": [m.get("major") for m in summary.get("majors") or []],
},
@@ -341,7 +347,12 @@ def main() -> int:
"total_failed": failed,
"rest": {
"total": rest_summary.get("total", 0),
"probed": rest_summary.get("probed", rest_summary.get("total", 0)),
"declared": rest_summary.get("declared", 0),
"critical": rest_summary.get("critical", rest_summary.get("failed", 0)),
"failed": rest_summary.get("failed", 0),
"coverage_line": rest_summary.get("coverage_line"),
"probed_eq_declared": rest_summary.get("probed_eq_declared"),
"by_verb": rest_summary.get("by_verb"),
"majors": rest_summary.get("majors"),
"failures": rest_summary.get("failures"),
@@ -386,7 +397,8 @@ def main() -> int:
print(f"Wrote {JUNIT_PATH}", flush=True)
print(
f"SUMMARY failed={failed} total={len(results)} "
f"rest.failed={summary['rest']['failed']} "
f"rest.coverage={summary['rest'].get('coverage_line')} "
f"rest.critical={summary['rest']['critical']} "
f"crud.failed={summary['crud']['failed']} "
f"soap.failed={summary['soap']['failed']}",
flush=True,
+515
View File
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""Build a compact vSphere REST param index from official Automation OpenAPI.
Source (default):
https://raw.githubusercontent.com/vmware/vcf-api-specs/main/specifications/vsphere/openapi/automation/vcenter.yaml
Output:
app/vsphere/rest/param_index.json
Keys are ``VERB /api/...`` matching the simulator catalog (OpenAPI servers already
use ``/api`` as the base). Paths that OpenAPI encodes as
``/vcenter/vm/{vm}/power?action=start`` are collapsed onto the template path with
an ``action`` query field (enum of all observed actions).
Requires PyYAML at generation time only (not a runtime dependency).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.vsphere.rest.param_fields import body_fields_from_example # noqa: E402
DEFAULT_URL = (
"https://raw.githubusercontent.com/vmware/vcf-api-specs/main/"
"specifications/vsphere/openapi/automation/vcenter.yaml"
)
OUT = ROOT / "app" / "vsphere" / "rest" / "param_index.json"
_PATH_QUERY = re.compile(r"^(?P<path>[^?]+)(?:\?(?P<query>.*))?$")
_ENUM_CAP = 24
# Prefer lab seed identifiers when property names match.
# Filter examples must AND-match on every profile (small / large / demo-cluster):
# web-01 (vm-101) is always POWERED_ON on host-11.
_LAB_EXAMPLES: dict[str, Any] = {
"vm": "vm-101",
"vms": ["vm-101"],
"name": "web-01",
"names": ["web-01"],
"host": "host-11",
"hosts": ["host-11"],
"folder": "group-v23",
"folders": ["group-v23"],
"datastore": "datastore-31",
"datastores": ["datastore-31"],
"datacenter": "datacenter-21",
"datacenters": ["datacenter-21"],
"cluster": "domain-c21",
"clusters": ["domain-c21"],
"resource_pool": "resgroup-22",
"resource_pools": ["resgroup-22"],
"network": "network-41",
"networks": ["network-41"],
"power_states": ["POWERED_ON"],
"guest_os": "OTHER_GUEST_64",
"guest_OS": "OTHER_GUEST_64",
"action": "start",
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
"item_id": "item-demo",
"library_id": "library-demo",
"task": "task-1",
"snapshot": "snapshot-1",
"count": 2,
"size_mib": 2048,
"size_MiB": 2048,
"memory_size_MiB": 2048,
"cpu_count": 2,
}
def _load_yaml(path: Path) -> dict[str, Any]:
try:
import yaml
except ImportError as exc: # pragma: no cover
raise SystemExit("PyYAML is required to generate the param index") from exc
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise SystemExit("OpenAPI root must be a mapping")
return data
def _fetch(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"Fetching {url}", file=sys.stderr)
with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310
dest.write_bytes(resp.read())
print(f"Wrote {dest} ({dest.stat().st_size} bytes)", file=sys.stderr)
def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
if not ref.startswith("#/"):
return {}
node: Any = spec
for part in ref[2:].split("/"):
if not isinstance(node, dict):
return {}
node = node.get(part)
return node if isinstance(node, dict) else {}
def _deref(spec: dict[str, Any], schema: dict[str, Any] | None) -> dict[str, Any]:
if not schema:
return {}
if "$ref" in schema:
resolved = _resolve_ref(spec, str(schema["$ref"]))
merged = dict(resolved)
for key, value in schema.items():
if key != "$ref":
merged[key] = value
return _deref(spec, merged) if ("$ref" in merged or "allOf" in merged) else merged
if "allOf" in schema and isinstance(schema["allOf"], list):
merged: dict[str, Any] = {}
props: dict[str, Any] = {}
required: list[str] = []
for part in schema["allOf"]:
if not isinstance(part, dict):
continue
resolved = _deref(spec, part)
for key, value in resolved.items():
if key == "properties" and isinstance(value, dict):
props.update(value)
elif key == "required" and isinstance(value, list):
required.extend(str(item) for item in value)
elif key not in {"properties", "required"}:
merged[key] = value
for key, value in schema.items():
if key == "allOf":
continue
if key == "properties" and isinstance(value, dict):
props.update(value)
elif key == "required" and isinstance(value, list):
required.extend(str(item) for item in value)
else:
merged[key] = value
if props:
merged["properties"] = props
if required:
merged["required"] = list(dict.fromkeys(required))
if "type" not in merged and props:
merged["type"] = "object"
return merged
return schema
def _short_desc(text: Any) -> str | None:
if not isinstance(text, str) or not text.strip():
return None
line = text.strip().split("\n", 1)[0].strip()
if len(line) > 160:
return line[:157] + "..."
return line
def _example_for(
spec: dict[str, Any],
name: str,
schema: dict[str, Any],
*,
depth: int = 0,
) -> Any:
schema = _deref(spec, schema)
if name in _LAB_EXAMPLES:
return _LAB_EXAMPLES[name]
if "example" in schema:
return schema["example"]
if "default" in schema:
return schema["default"]
enum = schema.get("enum")
if isinstance(enum, list) and enum:
return enum[0]
typ = schema.get("type")
if typ == "array":
items = schema.get("items") if isinstance(schema.get("items"), dict) else {}
item_ex = _example_for(spec, name.rstrip("s") or name, items, depth=depth + 1)
return [item_ex] if item_ex is not None else []
if typ == "object" or "properties" in schema:
if depth >= 3:
return {}
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
req = set(schema.get("required") or [])
# Prefer required props; include a few well-known optional lab fields.
keys = list(req)
for extra in ("name", "placement", "cpu", "memory", "description", "spec", "create_spec"):
if extra in props and extra not in keys:
keys.append(extra)
if not keys:
keys = list(props.keys())[:6]
out: dict[str, Any] = {}
for key in keys:
prop = props.get(key)
if not isinstance(prop, dict):
continue
out[key] = _example_for(spec, key, prop, depth=depth + 1)
return out
if typ == "boolean":
return False
if typ == "integer":
return int(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1
if typ == "number":
return float(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1.0
if typ == "string" or typ is None:
return "example"
return None
def _field_from_schema(
spec: dict[str, Any],
name: str,
schema: dict[str, Any],
*,
optional: bool,
description: str | None = None,
) -> dict[str, Any]:
schema = _deref(spec, schema)
typ = schema.get("type")
if typ is None and "properties" in schema:
typ = "object"
if typ is None and "items" in schema:
typ = "array"
enum = schema.get("enum") if isinstance(schema.get("enum"), list) else []
if len(enum) > _ENUM_CAP:
enum = enum[:_ENUM_CAP]
example = _example_for(spec, name, schema)
return {
"name": name,
"type": typ or "string",
"optional": optional,
"description": _short_desc(description or schema.get("description")),
"enum": enum,
"example": example,
}
def _collect_params(
spec: dict[str, Any],
operation: dict[str, Any],
path_item_params: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
query: list[dict[str, Any]] = []
path: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for raw in list(path_item_params) + list(operation.get("parameters") or []):
if not isinstance(raw, dict):
continue
param = _deref(spec, raw) if "$ref" in raw else raw
name = str(param.get("name") or "")
location = str(param.get("in") or "")
if not name or location not in {"query", "path"}:
continue
key = (location, name)
if key in seen:
continue
seen.add(key)
schema = param.get("schema") if isinstance(param.get("schema"), dict) else {}
field = _field_from_schema(
spec,
name,
schema,
optional=not bool(param.get("required")),
description=param.get("description") if isinstance(param.get("description"), str) else None,
)
if location == "query":
query.append(field)
else:
path.append(field)
return query, path
def _body_from_operation(
spec: dict[str, Any],
operation: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
request_body = operation.get("requestBody")
if not isinstance(request_body, dict):
return [], {}
request_body = _deref(spec, request_body) if "$ref" in request_body else request_body
content = request_body.get("content") if isinstance(request_body.get("content"), dict) else {}
media = content.get("application/json") or next(iter(content.values()), None)
if not isinstance(media, dict):
return [], {}
schema = _deref(spec, media.get("schema") if isinstance(media.get("schema"), dict) else {})
if not schema:
return [], {}
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
if props:
example = _example_for(spec, "", schema)
example_dict = example if isinstance(example, dict) else {}
# PARAM drawer leaves come from nested body_example (placement.host, …).
return body_fields_from_example(example_dict), example_dict
# Body is a naked $ref / non-object — still emit an example blob.
example = _example_for(spec, "body", schema)
example_dict = example if isinstance(example, dict) else {}
return body_fields_from_example(example_dict), example_dict
def _normalize_path_key(raw_path: str) -> tuple[str, dict[str, str]]:
"""Return (/api/... template, query extras from path key like action=start)."""
match = _PATH_QUERY.match(raw_path)
if not match:
return raw_path, {}
path = match.group("path")
query_raw = match.group("query") or ""
extras: dict[str, str] = {}
if query_raw:
for part in query_raw.split("&"):
if "=" in part:
key, value = part.split("=", 1)
extras[key] = value
elif part:
extras[part] = ""
if not path.startswith("/api/"):
path = "/api" + path if path.startswith("/") else "/api/" + path
return path, extras
def _merge_action_field(query_fields: list[dict[str, Any]], actions: set[str]) -> None:
if not actions:
return
existing = next((field for field in query_fields if field["name"] == "action"), None)
ordered = sorted(actions)
if existing is None:
query_fields.insert(
0,
{
"name": "action",
"type": "string",
"optional": False,
"description": "Operation action query parameter",
"enum": ordered,
"example": ordered[0],
},
)
return
enum = list(dict.fromkeys([*list(existing.get("enum") or []), *ordered]))
existing["enum"] = enum
existing["optional"] = False
if not existing.get("example"):
existing["example"] = enum[0]
def build_index(spec: dict[str, Any]) -> dict[str, Any]:
paths = spec.get("paths") if isinstance(spec.get("paths"), dict) else {}
# Accumulate by VERB + template path so ?action= variants collapse.
buckets: dict[str, dict[str, Any]] = {}
def ensure_bucket(verb: str, template: str) -> dict[str, Any]:
key = f"{verb} {template}"
return buckets.setdefault(
key,
{
"verb": verb,
"path": template,
"query_fields": [],
"path_fields": [],
"body_fields": [],
"body_example": {},
"operation_ids": [],
"actions": set(),
},
)
def merge_operation(
*,
verb: str,
template: str,
operation: dict[str, Any],
path_params: list[dict[str, Any]],
action: str | None = None,
) -> None:
key = f"{verb} {template}"
if action:
existing = buckets.get(key)
# Do not pollute CreateSpec-style POSTs with clone/register actions.
if existing is not None and (existing["body_fields"] or existing["body_example"]):
return
bucket = ensure_bucket(verb, template)
bucket["actions"].add(action)
_query, path_fields = _collect_params(spec, operation, path_params)
for field in path_fields:
names = {item["name"] for item in bucket["path_fields"]}
if field["name"] not in names:
bucket["path_fields"].append(field)
op_id = operation.get("operationId")
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
bucket["operation_ids"].append(op_id)
return
bucket = ensure_bucket(verb, template)
query_fields, path_fields = _collect_params(spec, operation, path_params)
body_fields, body_example = _body_from_operation(spec, operation)
for field in query_fields:
names = {item["name"] for item in bucket["query_fields"]}
if field["name"] not in names:
bucket["query_fields"].append(field)
for field in path_fields:
names = {item["name"] for item in bucket["path_fields"]}
if field["name"] not in names:
bucket["path_fields"].append(field)
if body_fields and not bucket["body_fields"]:
bucket["body_fields"] = body_fields
if body_example and not bucket["body_example"]:
bucket["body_example"] = body_example
op_id = operation.get("operationId")
if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
bucket["operation_ids"].append(op_id)
# Pass 1: concrete paths without ?query — establish CreateSpec etc.
# Pass 2: ?action= variants — attach only onto action-style endpoints.
ordered_paths = sorted(paths.items(), key=lambda item: ("?" in str(item[0]), str(item[0])))
for raw_path, path_item in ordered_paths:
if not isinstance(path_item, dict):
continue
template, extras = _normalize_path_key(str(raw_path))
path_params = [p for p in (path_item.get("parameters") or []) if isinstance(p, dict)]
for verb, operation in path_item.items():
if verb.startswith("x-") or verb == "parameters" or not isinstance(operation, dict):
continue
upper = verb.upper()
if upper not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
continue
merge_operation(
verb=upper,
template=template,
operation=operation,
path_params=path_params,
action=extras.get("action") or None,
)
methods: dict[str, Any] = {}
for key, bucket in sorted(buckets.items()):
actions: set[str] = bucket.pop("actions")
_merge_action_field(bucket["query_fields"], actions)
entry = {
"verb": bucket["verb"],
"path": bucket["path"],
"query_fields": bucket["query_fields"],
"path_fields": bucket["path_fields"],
"body_fields": bucket["body_fields"],
"body_example": bucket["body_example"],
"operation_ids": bucket["operation_ids"],
}
methods[key] = entry
info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
return {
"source": "vmware/vcf-api-specs specifications/vsphere/openapi/automation/vcenter.yaml",
"openapi": spec.get("openapi"),
"title": info.get("title"),
"version": info.get("version"),
"method_count": len(methods),
"methods": methods,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_URL, help="OpenAPI YAML URL")
parser.add_argument(
"--input",
type=Path,
help="Local OpenAPI YAML (skips download)",
)
parser.add_argument(
"--cache",
type=Path,
default=ROOT / ".cache" / "vcenter.openapi.yaml",
help="Download cache path",
)
parser.add_argument("--output", type=Path, default=OUT)
args = parser.parse_args()
if args.input is not None:
source = args.input
else:
if not args.cache.exists():
_fetch(args.url, args.cache)
source = args.cache
spec = _load_yaml(source)
index = build_index(spec)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(
f"Wrote {args.output} methods={index['method_count']} version={index.get('version')}",
file=sys.stderr,
)
# Spot-check critical routes.
for probe in ("GET /api/vcenter/vm", "POST /api/vcenter/vm", "POST /api/vcenter/vm/{vm}/power"):
entry = index["methods"].get(probe)
if not entry:
print(f"WARN missing {probe}", file=sys.stderr)
continue
print(
f"OK {probe} query={len(entry['query_fields'])} "
f"body_fields={len(entry['body_fields'])} "
f"body_example_keys={list(entry['body_example'])[:6]}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Generate a gitignored docker-compose.override.yml on free host ports, then
# start the stack. Invoked by `make up-local`.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
COMPOSE="${COMPOSE:-docker compose}"
COMPOSE_OVERRIDE="${COMPOSE_OVERRIDE:-docker-compose.override.yml}"
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-18080}"
LOCAL_HTTPS_PORT="${LOCAL_HTTPS_PORT:-18443}"
LOCAL_POSTGRES_PORT="${LOCAL_POSTGRES_PORT:-15434}"
port_ok() {
local p="$1"
if ! lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1; then
return 0
fi
# Reuse ports already published by this compose project.
$COMPOSE port api-gateway 80 2>/dev/null | grep -q ":${p}$" && return 0
$COMPOSE port api-gateway 443 2>/dev/null | grep -q ":${p}$" && return 0
$COMPOSE port postgres 5432 2>/dev/null | grep -q ":${p}$" && return 0
return 1
}
pick() {
local start="$1" name="$2" p
for p in $(seq "$start" $((start + 40))); do
if port_ok "$p"; then
echo "$p"
return 0
fi
done
echo "No free host port near ${start} for ${name}" >&2
exit 1
}
test -f .env || cp .env.example .env
http="$(pick "$LOCAL_HTTP_PORT" HTTP)"
https="$(pick "$LOCAL_HTTPS_PORT" HTTPS)"
pg="$(pick "$LOCAL_POSTGRES_PORT" Postgres)"
cat >"$COMPOSE_OVERRIDE" <<EOF
# Generated by make up-local — gitignored, do not commit.
services:
postgres:
ports:
- "127.0.0.1:${pg}:5432"
api-gateway:
ports:
- "${http}:80"
- "${https}:443"
EOF
echo "Wrote ${COMPOSE_OVERRIDE}: HTTP=${http} HTTPS=${https} Postgres=127.0.0.1:${pg}"
$COMPOSE up -d --build --wait
echo ""
echo "Local stack is up (override ports, not committed):"
echo " HTTPS https://localhost:${https}"
echo " HTTP http://localhost:${http}"
echo " DB 127.0.0.1:${pg}"
+325
View File
@@ -0,0 +1,325 @@
#!/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())
+2 -1
View File
@@ -40,7 +40,8 @@ def _concrete(path: str) -> str:
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
# Disposable id — seed resgroup-22 is protected from DELETE.
"{resource_pool}": "resgroup-missing",
"{permission_id}": "1",
"{policy}": "policy-default",
"{disk}": "2000",
@@ -26,7 +26,7 @@ async def client() -> AsyncClient:
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="demo-cluster")
await seed_vsphere_inventory(app.state.database, force=True, profile="big")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
@@ -42,7 +42,7 @@ async def test_demo_cluster_api_state_and_inventory(client: AsyncClient) -> None
vm_list = await client.get("/api/vcenter/vm", headers=headers)
assert vm_list.status_code == 200
assert len(vm_list.json()) >= 1000
assert len(vm_list.json()) >= 2000
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
@@ -0,0 +1,228 @@
"""Deep-handler wire realism: seed non-empty, mutations, tasks, REST↔SOAP."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(client: AsyncClient) -> dict[str, str]:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert login.status_code == 201
return {"vmware-api-session-id": login.json()}
async def test_deep_inventory_lists_and_details(client: AsyncClient) -> None:
headers = await _session(client)
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
assert len(hosts.json()) >= 3
host_id = hosts.json()[0]["host"]
host = await client.get(f"/api/vcenter/host/{host_id}", headers=headers)
assert host.status_code == 200
assert host.json()["name"]
stores = await client.get("/api/vcenter/datastore", headers=headers)
assert stores.status_code == 200
ds_id = stores.json()[0]["datastore"]
detail = await client.get(f"/api/vcenter/datastore/{ds_id}", headers=headers)
assert detail.status_code == 200
files = await client.get(f"/api/vcenter/datastore/{ds_id}/files", headers=headers)
assert files.status_code == 200
assert len(files.json()) >= 1
async def test_content_library_deep_get_not_stub(client: AsyncClient) -> None:
headers = await _session(client)
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert "lib-local-1" in libs.json()
local = await client.get("/api/content/local-library", headers=headers)
assert local.status_code == 200
assert "lib-local-1" in local.json()
info = await client.get("/api/content/library/lib-local-1", headers=headers)
assert info.status_code == 200
body = info.json()
assert body["id"] == "lib-local-1"
assert body["name"] == "Local Content"
assert body["type"] == "LOCAL"
assert "path" not in body # stub placeholder must not leak
local_info = await client.get("/api/content/local-library/lib-local-1", headers=headers)
assert local_info.status_code == 200
assert local_info.json()["id"] == "lib-local-1"
items = await client.get(
"/api/content/library/item",
params={"library_id": "lib-local-1"},
headers=headers,
)
assert items.status_code == 200
assert "item-ubuntu" in items.json()
item = await client.get("/api/content/library/item/item-ubuntu", headers=headers)
assert item.status_code == 200
assert item.json()["name"] == "ubuntu-22.04"
assert item.json()["library_id"] == "lib-local-1"
async def test_power_task_poll_and_soap_consistency(client: AsyncClient) -> None:
headers = await _session(client)
power = await client.post(
"/api/vcenter/vm/vm-104/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 200
task_id = power.json()["task"]
assert task_id.startswith("task-")
task = await client.get(f"/api/cis/tasks/{task_id}", headers=headers)
assert task.status_code == 200
payload = task.json()
assert payload["status"] == "SUCCEEDED"
assert payload["state"] == "SUCCEEDED"
assert isinstance(payload["description"], dict)
assert payload["description"]["default_message"]
assert isinstance(payload["progress"], dict)
assert payload["progress"]["completed"] == 100
assert payload["result"]["vm"] == "vm-104"
listed = await client.post(
"/api/cis/tasks",
params={"action": "list"},
headers=headers,
json={"filter_spec": {"tasks": [task_id]}},
)
assert listed.status_code == 200
assert task_id in listed.json()
state = await client.get("/api/vcenter/vm/vm-104/power", headers=headers)
assert state.json()["state"] == "POWERED_ON"
soap = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert soap.status_code == 200
cookie = (soap.headers.get("set-cookie") or "").split(";")[0]
power_soap = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<PowerOffVM_Task xmlns="urn:vim25">
<_this type="VirtualMachine">vm-104</_this>
</PowerOffVM_Task>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert power_soap.status_code == 200
assert "task-" in power_soap.text
assert 'type="Task"' in power_soap.text
async def test_tagging_association_query_action(client: AsyncClient) -> None:
headers = await _session(client)
tags = await client.get("/api/cis/tagging/tag", headers=headers)
assert tags.status_code == 200
tag_id = tags.json()[0]
attach = await client.post(
"/api/cis/tagging/tag-association",
params={"action": "attach"},
headers=headers,
json={"tag_id": tag_id, "object_id": {"type": "VirtualMachine", "id": "vm-103"}},
)
assert attach.status_code == 204
listed = await client.post(
"/api/cis/tagging/tag-association",
params={"action": "list-attached-tags"},
headers=headers,
json={"object_id": {"type": "VirtualMachine", "id": "vm-103"}},
)
assert listed.status_code == 200
assert tag_id in listed.json()
async def test_folder_create_and_authz_permissions(client: AsyncClient) -> None:
headers = await _session(client)
folder = await client.post(
"/api/vcenter/folder",
headers=headers,
json={"name": "deep-repro-folder", "type": "VIRTUAL_MACHINE", "parent": "group-v23"},
)
assert folder.status_code == 200
assert folder.json().startswith("group-")
roles = await client.get("/api/vcenter/authorization/roles", headers=headers)
assert roles.status_code == 200
assert any(r["role"] == "Administrator" for r in roles.json())
perms = await client.get("/api/vcenter/authorization/permissions", headers=headers)
assert perms.status_code == 200
assert len(perms.json()) >= 1
async def test_ovf_deploy_accepts_official_target_fields(client: AsyncClient) -> None:
headers = await _session(client)
deploy = await client.post(
"/api/vcenter/ovf/library-item/item-ubuntu",
headers=headers,
json={
"target": {
"resource_pool_id": "resgroup-22",
"folder_id": "group-v23",
"host_id": "host-11",
"datastore_id": "datastore-31",
},
"deployment_spec": {"name": "ovf-deep-repro", "accept_all_EULA": True},
},
)
assert deploy.status_code == 200
body = deploy.json()
assert body["resource_id"]["type"] == "VirtualMachine"
assert body["resource_id"]["id"].startswith("vm-")
assert body["task"].startswith("task-")
vm = await client.get(f"/api/vcenter/vm/{body['resource_id']['id']}", headers=headers)
assert vm.status_code == 200
assert vm.json()["name"] == "ovf-deep-repro"
+26
View File
@@ -0,0 +1,26 @@
"""HEAD is served for GET Automation routes (contract matrix)."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.middleware import HeadAsGetMiddleware
def test_head_as_get_middleware_strips_body() -> None:
app = FastAPI()
app.add_middleware(HeadAsGetMiddleware)
@app.get("/api/vcenter/vm")
def list_vms() -> list[dict[str, str]]:
return [{"vm": "vm-101", "name": "web-01"}]
client = TestClient(app)
get = client.get("/api/vcenter/vm")
assert get.status_code == 200
assert get.json()[0]["vm"] == "vm-101"
head = client.head("/api/vcenter/vm")
assert head.status_code == 200
assert head.content in (b"", None) or head.text == ""
+33
View File
@@ -0,0 +1,33 @@
"""Tests for nested PARAM field extraction from body_example."""
from __future__ import annotations
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
def test_body_fields_from_example_flattens_nested_scalars() -> None:
fields = body_fields_from_example(
{
"name": "lab-vm",
"placement": {"host": "host-11", "folder": "group-v23"},
"cpu": {"count": 2, "cores_per_socket": 1},
"disks": [{"new_vmdk": {"name": "disk-0", "capacity": 1024}}],
}
)
by_name = {field["name"]: field for field in fields}
assert by_name["name"]["example"] == "lab-vm"
assert by_name["name"]["type"] == "string"
assert by_name["placement.host"]["example"] == "host-11"
assert by_name["cpu.count"]["type"] == "integer"
assert by_name["cpu.count"]["example"] == 2
assert by_name["disks.0.new_vmdk.name"]["example"] == "disk-0"
assert by_name["disks.0.new_vmdk.capacity"]["type"] == "integer"
assert "placement" not in by_name
assert "disks" not in by_name
def test_set_by_path_builds_nested_dicts() -> None:
root: dict = {}
set_by_path(root, "placement.host", "host-11")
set_by_path(root, "cpu.count", 2)
assert root == {"placement": {"host": "host-11"}, "cpu": {"count": 2}}
+30
View File
@@ -0,0 +1,30 @@
"""Seed spine objects must not be deletable by probe traffic."""
import pytest
from app.vsphere.domain.inventory_ops import _SEED_PROTECTED_MOIDS, _is_seed_host_or_named_vm
from app.vsphere.inventory import ManagedObject
def test_seed_spine_includes_root_pool_and_named_vms() -> None:
assert "resgroup-22" in _SEED_PROTECTED_MOIDS
assert "domain-c21" in _SEED_PROTECTED_MOIDS
assert "vm-101" in _SEED_PROTECTED_MOIDS
assert "network-41" in _SEED_PROTECTED_MOIDS
@pytest.mark.parametrize("moid", ["host-11", "host-20", "host-30"])
def test_seed_hosts_are_protected(moid: str) -> None:
obj = ManagedObject(moid=moid, type="HostSystem", name="esxi", parent_moid="domain-c21", props={})
assert _is_seed_host_or_named_vm(moid, obj)
def test_probe_host_pattern_not_protected() -> None:
obj = ManagedObject(
moid="host-probe",
type="HostSystem",
name="probe",
parent_moid="domain-c21",
props={},
)
assert not _is_seed_host_or_named_vm("host-probe", obj)
+76 -1
View File
@@ -1,6 +1,7 @@
"""Native vSphere console catalog tests."""
from app.vsphere.contracts.catalog import (
_param_index,
list_vsphere_majors,
vsphere_catalog_payload,
vsphere_method_payload,
@@ -45,4 +46,78 @@ def test_vsphere_method_payload_extracts_path_fields() -> None:
assert payload["implemented"] is True
assert len(payload["path_fields"]) == 1
assert payload["path_fields"][0]["name"] == "vm"
assert payload["resolved_path"] == "/api/vcenter/vm/vm-111"
assert payload["resolved_path"] == "/api/vcenter/vm/vm-101"
def test_param_index_loaded_from_openapi() -> None:
_param_index.cache_clear()
methods = _param_index()
assert "GET /api/vcenter/vm" in methods
assert "POST /api/vcenter/vm" in methods
assert len(methods) > 500
def test_vsphere_method_payload_uses_openapi_query_filters() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm",
verb="GET",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
query_names = {field["name"] for field in payload["query_fields"]}
assert query_names >= {
"vms",
"names",
"folders",
"datacenters",
"hosts",
"clusters",
"resource_pools",
"power_states",
}
# GET has no JSON body in the real Automation API.
assert payload["body_example"] == {}
def test_vsphere_method_payload_uses_openapi_create_spec() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm",
verb="POST",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
assert payload["body_example"]["guest_os"] == "OTHER_GUEST_64"
assert payload["body_example"]["name"] == "web-01"
assert isinstance(payload["body_example"]["placement"], dict)
assert payload["body_example"]["placement"]["host"] == "host-11"
assert payload["body_example"]["cpu"]["count"] == 2
assert payload["body_example"]["memory"]["size_mib"] == 2048
body_names = {field["name"] for field in payload["body_fields"]}
assert "guest_os" in body_names
assert "name" in body_names
assert "placement.host" in body_names
assert "placement.folder" in body_names
assert "cpu.count" in body_names
assert "memory.size_mib" in body_names
# Nested object keys themselves are not PARAM rows — only scalar leaves.
assert "placement" not in body_names
assert "cpu" not in body_names
# Create must not inherit ?action=clone pollution.
assert not any(field["name"] == "action" for field in payload["query_fields"])
def test_vsphere_method_payload_power_action_query() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm/{vm}/power",
verb="POST",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
action = next(field for field in payload["query_fields"] if field["name"] == "action")
assert action["optional"] is False
assert set(action["enum"]) >= {"start", "stop", "reset", "suspend"}
# Params drawer merges query into body_fields for editing.
assert any(field["name"] == "action" for field in payload["body_fields"])
+76 -8
View File
@@ -1,27 +1,64 @@
"""vSphere seed profile shape tests (no database)."""
from app.vsphere.profiles import build_vsphere_profile, large_vsphere_profile, small_vsphere_profile
from app.vsphere.profiles import (
PROFILE_SIZES,
big_vsphere_profile,
build_vsphere_profile,
infer_profile_hint,
large_vsphere_profile,
minimal_vsphere_profile,
small_vsphere_profile,
)
from app.vsphere.security.authz import has_privilege, privileges_for_roles
def test_small_profile_has_named_vms() -> None:
profile = small_vsphere_profile()
def test_profile_sizes_table() -> None:
assert PROFILE_SIZES["minimal"].vm_count == 5
assert PROFILE_SIZES["small"].host_count == 3
assert PROFILE_SIZES["small"].vm_count == 50
assert PROFILE_SIZES["large"].host_count == 10
assert PROFILE_SIZES["large"].vm_count == 1000
assert PROFILE_SIZES["big"].host_count == 20
assert PROFILE_SIZES["big"].vm_count == 2000
def test_minimal_profile() -> None:
profile = minimal_vsphere_profile()
assert profile.name == "minimal"
assert profile.vm_count == 5
assert profile.host_count == 3
assert len([o for o in profile.objects if o.type == "Datastore"]) == 1
assert len([o for o in profile.objects if o.type == "Network"]) == 1
def test_small_profile_has_named_vms_and_scale() -> None:
profile = small_vsphere_profile()
assert profile.vm_count == 50
assert profile.host_count == 3
assert profile.extras_scale == 1
names = {obj.name for obj in profile.objects if obj.type == "VirtualMachine"}
assert {"web-01", "app-01", "db-01"} <= names
datastores = [obj for obj in profile.objects if obj.type == "Datastore"]
assert len(datastores) == 2
ds_ids = {d.moid for d in datastores}
for vm in profile.objects:
if vm.type != "VirtualMachine":
continue
assert vm.props.get("datastore") in ds_ids
def test_large_profile_1000_vms() -> None:
profile = large_vsphere_profile(host_count=10, vm_count=1000)
assert profile.vm_count == 1000
assert profile.host_count == 10
assert profile.extras_scale == 2
vms = [obj for obj in profile.objects if obj.type == "VirtualMachine"]
hosts = [obj for obj in profile.objects if obj.type == "HostSystem"]
folders = [obj for obj in profile.objects if obj.type == "Folder"]
assert len(vms) == 1000
assert len(hosts) == 10
# Named cookbooks survive at the front of large inventories.
assert len(folders) == 10 # 8 spine + 2 scaled
assert any(obj.name == "web-01" for obj in vms)
# Even spread across hosts
by_host: dict[str, int] = {}
for vm in vms:
host = str(vm.props.get("host"))
@@ -31,10 +68,41 @@ def test_large_profile_1000_vms() -> None:
assert max(by_host.values()) <= 110
def test_demo_cluster_profile() -> None:
profile = build_vsphere_profile("demo-cluster")
assert profile.vm_count == 1000
def test_big_profile_2000_vms() -> None:
profile = big_vsphere_profile()
assert profile.name == "big"
assert profile.vm_count == 2000
assert profile.host_count == 20
assert profile.extras_scale == 4
datastores = [obj for obj in profile.objects if obj.type == "Datastore"]
networks = [
obj
for obj in profile.objects
if obj.type in {"Network", "DistributedVirtualPortgroup"}
]
folders = [obj for obj in profile.objects if obj.type == "Folder"]
assert len(datastores) == 8
assert len(networks) == 8
assert len(folders) == 14 # 8 spine + 6 scaled
ds_ids = {d.moid for d in datastores}
for vm in profile.objects:
if vm.type != "VirtualMachine":
continue
assert vm.props.get("datastore") in ds_ids
def test_demo_cluster_aliases_big() -> None:
profile = build_vsphere_profile("demo-cluster")
assert profile.name == "big"
assert profile.vm_count == 2000
assert profile.host_count == 20
def test_infer_profile_hint() -> None:
assert infer_profile_hint(hosts=3, vms=5, datastores=1) == "minimal"
assert infer_profile_hint(hosts=3, vms=50, datastores=2) == "small"
assert infer_profile_hint(hosts=10, vms=1000, datastores=4) == "large"
assert infer_profile_hint(hosts=20, vms=2000, datastores=8) == "big"
def test_lab_credentials_include_readonly() -> None: