Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.
@@ -70,6 +70,7 @@ hypothesis/
|
||||
# Docker / local runtime
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
.cache/
|
||||
|
||||
# Terraform local state (never commit)
|
||||
*.tfstate
|
||||
|
||||
@@ -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 6–9 (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 '\
|
||||
|
||||
@@ -109,11 +109,11 @@ curl -sk https://localhost/api/appliance/system/version
|
||||
|
||||
Interactive console with light/dark themes, endpoint catalog for vSphere
|
||||
majors 6–9, 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).
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Credentials (seed)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Учётные данные (seed)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`` (1–4) 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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 6–9), 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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ curl -sk -H "vmware-api-session-id: $SID" \
|
||||
|
||||
Откройте [https://localhost/](https://localhost/) — интерактивная
|
||||
консоль, каталог эндпоинтов (vSphere majors 6–9), вид совместимости, apply
|
||||
runtime-контракта и управление demo-cluster. Скриншоты светлой/тёмной темы и
|
||||
полный список возможностей — [Web UI](web-ui.md).
|
||||
runtime-контракта и управление demo-cluster. Скриншоты и полный список
|
||||
возможностей — [Web UI](web-ui.md).
|
||||
|
||||
## 8. Попробуйте клиентскую библиотеку
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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. Она поддерживает светлую и тёмную темы, мажоры каталога
|
||||
**6–9** (уровни vSphere 7–8.0U2), редактирование запроса/ответа, историю и
|
||||
применение runtime-контракта.
|
||||
|
||||
## Скриншоты
|
||||
|
||||
### Главная консоль
|
||||
|
||||

|
||||
|
||||
### Каталог API (мажоры 6–9)
|
||||
|
||||

|
||||
|
||||
### Браузер endpoint'ов
|
||||
|
||||

|
||||
|
||||
### Запрос / ответ
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### История
|
||||
|
||||

|
||||
|
||||
### Аутентификация
|
||||
|
||||

|
||||
|
||||
### Данные / seed
|
||||
|
||||

|
||||
|
||||
### Окружение
|
||||
|
||||

|
||||
|
||||
### Справка — совместимость
|
||||
|
||||

|
||||
|
||||
## Возможности
|
||||
|
||||
- Дерево 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 работы с версиями
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 **6–9** (vSphere 7–8.0U2
|
||||
floors), request/response editing, history, and runtime contract apply.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main console
|
||||
|
||||

|
||||
|
||||
### API catalog (majors 6–9)
|
||||
|
||||

|
||||
|
||||
### Endpoint browser
|
||||
|
||||

|
||||
|
||||
### Request / response
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### History
|
||||
|
||||

|
||||
|
||||
### Authentication
|
||||
|
||||

|
||||
|
||||
### Data / seed
|
||||
|
||||

|
||||
|
||||
### Environment
|
||||
|
||||

|
||||
|
||||
### Help — compatibility
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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 **6–9** matrix (deep + stub response checks)
|
||||
1. **Layer A (required for “100%”)** — HTTP contract matrix: all `IMPLEMENTED`
|
||||
REST `verb+path` × catalog majors **6–9**, 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 auth’d 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 6–9 (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 6–9 + 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×6–9 + 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 ×6–9 + 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/
|
||||
|
||||
@@ -2,77 +2,42 @@
|
||||
|
||||
Гибрид под `pulumi-tests/`:
|
||||
|
||||
1. Официальный [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) (SOAP/VIM через govmomi)
|
||||
2. Полная REST-матрица `IMPLEMENTED` × majors **6–9** (deep + stub)
|
||||
1. **Layer A (обязателен для «100%»)** — HTTP contract matrix: все `IMPLEMENTED`
|
||||
REST `verb+path` × majors **6–9**, плюс синтетический **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 6–9 (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` × 6–9 + 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×6–9 + 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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 6–9 with session auth.
|
||||
Verbs: whatever is in IMPLEMENTED (GET/PUT/PATCH/POST/DELETE) plus a synthetic
|
||||
HEAD for every GET path. Pass requires ``critical == 0`` and
|
||||
``probed == declared`` per major and in aggregate.
|
||||
|
||||
100% coverage here means the HTTP contract matrix — not pulumi-vsphere resource
|
||||
count (Layer B).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -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==
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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 == ""
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||