Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# pulumi-vsphere lab suite (containers only).
COMPOSE ?= docker compose
COMPOSE_FILE ?= docker-compose.yml
COMPOSE_CMD = $(COMPOSE) -f $(COMPOSE_FILE)
PROFILE = --profile test
.PHONY: help up down build seed test-pulumi-smoke test-pulumi pulumi-tests \
test-smoke-all test-all clean-test-resources report-open
help: ## Show targets
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-26s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
up: ## Start simulator + gateway + seed
$(COMPOSE_CMD) up -d --build postgres
$(COMPOSE_CMD) up --build migrate
$(COMPOSE_CMD) up -d --build simulator api-gateway
$(COMPOSE_CMD) up --build seed
down: ## Stop lab stack
$(COMPOSE_CMD) --profile test down -v
build: ## Build pulumi-runner image (pulumi + pulumi-vsphere)
$(COMPOSE_CMD) $(PROFILE) build pulumi-runner
seed: ## Re-run inventory seed
$(COMPOSE_CMD) up --build seed
test-pulumi-smoke: up ## PU-INV + REST major-9 smoke (no VM/tags/CRUD/full SOAP)
$(COMPOSE_CMD) $(PROFILE) run --rm -e TEST_SMOKE=1 pulumi-runner \
python3 /suite/run_suite.py
test-pulumi: up ## Full hybrid: pulumi-vsphere + REST×6-9 + CRUD + SOAP WSDL
$(COMPOSE_CMD) $(PROFILE) run --rm pulumi-runner \
python3 /suite/run_suite.py
pulumi-tests: test-pulumi ## Alias used from repo root
test-smoke-all: test-pulumi-smoke ## Alias
test-all: test-pulumi ## Alias
clean-test-resources: ## Best-effort cleanup via seed reload
$(COMPOSE_CMD) up --build seed
report-open: ## Print how to copy the HTML report
@echo "HTML report volume: lab-reports → /reports/pulumi-report.html"
@echo "Copy: docker compose --profile test run --rm --no-deps -v \$$(pwd)/reports:/out pulumi-runner cp /reports/pulumi-report.html /out/"
+84
View File
@@ -0,0 +1,84 @@
# pulumi hybrid lab suite for vmware-api-simulator
Hybrid suite under `pulumi-tests/`:
1. Official [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) (SOAP/VIM via govmomi)
2. Full REST `IMPLEMENTED` × majors **69** matrix (deep + stub response checks)
3. Deep REST CRUD (session / folder / tag / content library / VM)
4. All SOAP WSDL ops advertised on `/sdk/vimService.wsdl`
`pulumi-vsphere` alone cannot hit ~1092 REST routes — the HTTP matrix is required for full-surface confidence.
## Pass rules
| 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) |
| 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 |
## What runs
| Case | Layer | Notes |
|------|-------|-------|
| `PU-INV` | pulumi-vsphere | Inventory data sources |
| `PU-FOLDER` | pulumi-vsphere | Folder create/destroy |
| `PU-VM` | pulumi-vsphere | VirtualMachine create/destroy |
| `PU-TAG` | pulumi-vsphere | TagCategory + Tag |
| `PU-REST` | REST matrix | `IMPLEMENTED` × majors 69 (smoke: major 9 only) |
| `PU-CRUD` | REST CRUD | Session, folder, tagging, content library, VM |
| `PU-SOAP` | SOAP | All WSDL ops |
Artifacts: HTML + JSON + JUnit under the `lab-reports` volume. JSON includes
`rest.total` / `rest.failed`, `crud.failed`, `soap.failed`.
## Quick start
From the **repo root**:
```bash
make pulumi-tests # full hybrid suite
make pulumi-tests-smoke # PU-INV + one-major REST smoke
```
Or from this directory:
```bash
cd pulumi-tests
make up
make test-pulumi # or: make test-pulumi-smoke
```
Gateway (in-compose): `https://api-gateway` (host map `127.0.0.1:18443`).
Seed profile: `small``Datacenter` / `Cluster` / `datastore1` / `VM Network` /
`web-01` / `esxi01.lab.local` / `/Datacenter/vm/production`.
## Make targets
| Target | Meaning |
|--------|---------|
| `make test-pulumi` / `pulumi-tests` | Full hybrid: pulumi-vsphere + REST×69 + CRUD + SOAP |
| `make test-pulumi-smoke` | `PU-INV` + REST major-9 smoke (no VM/tags/CRUD/SOAP) |
| `make up` / `down` / `seed` | Lab stack lifecycle |
## Layout
```
pulumi-tests/
run_suite.py # Automation API + REST/SOAP probes
report_html.py
lib/assert_nonempty.py
lib/rest_matrix.py # IMPLEMENTED × majors
lib/rest_crud.py # deep create/read/update/delete
lib/soap_ops.py # WSDL SOAP ops
programs/inventory/
programs/folders/
programs/vm_lifecycle/
programs/tags/
docker/Dockerfile.pulumi-runner
docker-compose.yml
```
`PYTHONPATH` mounts `/workspace` so probes import `app.vsphere.*` coverage/matrix.
+78
View File
@@ -0,0 +1,78 @@
# Гибридный lab-набор pulumi для vmware-api-simulator
Гибрид под `pulumi-tests/`:
1. Официальный [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) (SOAP/VIM через govmomi)
2. Полная REST-матрица `IMPLEMENTED` × majors **69** (deep + stub)
3. Deep REST CRUD (session / folder / tag / content library / VM)
4. Все SOAP-операции из `/sdk/vimService.wsdl`
Одним `pulumi-vsphere` закрыть ~1092 REST `verb×path` нельзя — HTTP-матрица обязательна для полной уверенности.
## Критерии pass
| Класс | Правило |
|-------|---------|
| 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’ы |
## Что запускается
| Кейс | Слой | Заметки |
|------|------|---------|
| `PU-INV` | pulumi-vsphere | Inventory data sources |
| `PU-FOLDER` | pulumi-vsphere | Folder create/destroy |
| `PU-VM` | pulumi-vsphere | VirtualMachine create/destroy |
| `PU-TAG` | pulumi-vsphere | TagCategory + Tag |
| `PU-REST` | REST matrix | `IMPLEMENTED` × majors 69 (smoke: только major 9) |
| `PU-CRUD` | REST CRUD | Session, folder, tagging, content library, VM |
| `PU-SOAP` | SOAP | Все WSDL ops |
Артефакты: HTML + JSON + JUnit в volume `lab-reports`. В JSON:
`rest.total` / `rest.failed`, `crud.failed`, `soap.failed`.
## Быстрый старт
Из **корня репозитория**:
```bash
make pulumi-tests # полный гибрид
make pulumi-tests-smoke # PU-INV + REST smoke на одном major
```
Или из этой директории:
```bash
cd pulumi-tests
make up
make test-pulumi # или: make test-pulumi-smoke
```
Шлюз (в compose): `https://api-gateway` (с хоста `127.0.0.1:18443`).
Seed-профиль: `small``Datacenter` / `Cluster` / `datastore1` / `VM Network` /
`web-01` / `esxi01.lab.local` / `/Datacenter/vm/production`.
## Цели Make
| Цель | Смысл |
|------|--------|
| `make test-pulumi` / `pulumi-tests` | Полный гибрид: pulumi-vsphere + REST×69 + CRUD + SOAP |
| `make test-pulumi-smoke` | `PU-INV` + REST major-9 smoke (без VM/tags/CRUD/SOAP) |
| `make up` / `down` / `seed` | Жизненный цикл lab-стека |
## Структура
```
pulumi-tests/
run_suite.py
report_html.py
lib/rest_matrix.py
lib/rest_crud.py
lib/soap_ops.py
programs/...
```
`PYTHONPATH` монтирует `/workspace` для импорта `app.vsphere.*`.
+168
View File
@@ -0,0 +1,168 @@
# pulumi-vsphere lab suite against vmware-api-simulator.
# Usage:
# cd pulumi-tests && make up && make test-pulumi
# make pulumi-tests # from repo root
name: vmware-pulumi-tests
networks:
lab:
driver: bridge
volumes:
lab-postgres-data:
lab-reports:
x-vsphere-env: &vsphere-env
VSPHERE_USER: administrator@vsphere.local
VSPHERE_PASSWORD: "VMware1!"
VSPHERE_SERVER: api-gateway
VSPHERE_BASE: https://api-gateway
VSPHERE_DATACENTER: Datacenter
VSPHERE_DATASTORE: datastore1
VSPHERE_CLUSTER: Cluster
VSPHERE_NETWORK: VM Network
VSPHERE_VM_NAME: web-01
VSPHERE_HOST: esxi01.lab.local
VSPHERE_FOLDER_PATH: /Datacenter/vm/production
VSPHERE_RESOURCE_POOL: Resources
REPORT_DIR: /reports
REPORT_PATH: /reports/pulumi-junit.xml
HTML_REPORT_PATH: /reports/pulumi-report.html
JSON_REPORT_PATH: /reports/pulumi-summary.json
PULUMI_CONFIG_PASSPHRASE: lab
PULUMI_BACKEND_URL: file:///tmp/pulumi-state
x-sim-env: &sim-env
DATABASE_URL: postgresql://vmware:vmware@postgres:5432/vmware_simulator
ENABLE_PVE_STUB: "false"
SEED_VSPHERE_PROFILE: small
LOG_LEVEL: INFO
TICKET_SIGNING_KEY: lab-signing-key
APP_PORT: "8080"
services:
postgres:
image: postgres:17.5-bookworm
networks: [lab]
environment:
POSTGRES_DB: vmware_simulator
POSTGRES_USER: vmware
POSTGRES_PASSWORD: vmware
volumes:
- lab-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vmware -d vmware_simulator"]
interval: 5s
timeout: 3s
retries: 20
migrate:
build:
context: ..
dockerfile: Dockerfile
target: runtime
networks: [lab]
working_dir: /workspace
volumes:
- ..:/workspace
environment:
<<: *sim-env
PYTHONPATH: /workspace
depends_on:
postgres:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.db.migrate_cli"]
restart: "no"
simulator:
build:
context: ..
dockerfile: Dockerfile
target: dev
networks: [lab]
working_dir: /workspace
volumes:
- ..:/workspace
environment:
<<: *sim-env
PYTHONPATH: /workspace
depends_on:
migrate:
condition: service_completed_successfully
entrypoint: []
command:
[
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8080",
"--reload",
"--reload-dir",
"/workspace/app",
]
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)",
]
interval: 5s
timeout: 3s
retries: 30
start_period: 15s
seed:
build:
context: ..
dockerfile: Dockerfile
target: runtime
networks: [lab]
working_dir: /workspace
volumes:
- ..:/workspace
environment:
<<: *sim-env
PYTHONPATH: /workspace
depends_on:
simulator:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.simulation.seed_cli"]
restart: "no"
api-gateway:
image: nginx:1.28-alpine
networks: [lab]
depends_on:
simulator:
condition: service_healthy
volumes:
- ../docker/gateway/vmware-ports.conf:/etc/nginx/conf.d/default.conf:ro
- ../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"
pulumi-runner:
build:
context: .
dockerfile: docker/Dockerfile.pulumi-runner
networks: [lab]
working_dir: /suite
volumes:
- ./:/suite
- ..:/workspace:ro
- lab-reports:/reports
environment:
<<: *vsphere-env
depends_on:
seed:
condition: service_completed_successfully
api-gateway:
condition: service_started
profiles: ["test"]
@@ -0,0 +1,22 @@
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL https://get.pulumi.com | sh \
&& ln -s /root/.pulumi/bin/pulumi /usr/local/bin/pulumi \
&& mkdir -p /tmp/pulumi-state \
&& rm -rf /var/lib/apt/lists/*
# Python SDK + official VMware provider (SOAP/VIM via govmomi)
RUN pip install --no-cache-dir \
"pulumi>=3.120,<4" \
"pulumi-vsphere==4.17.0" \
&& pulumi plugin install resource vsphere 4.17.0
WORKDIR /suite
ENV PYTHONPATH=/suite:/suite/lib:/workspace \
PULUMI_CONFIG_PASSPHRASE=lab \
PULUMI_BACKEND_URL=file:///tmp/pulumi-state \
VSPHERE_SERVER=api-gateway \
VSPHERE_ALLOW_UNVERIFIED_SSL=true
CMD ["python3", "/suite/run_suite.py"]
+12
View File
@@ -0,0 +1,12 @@
# Pulumi lab suite (compose).
VSPHERE_USER=administrator@vsphere.local
VSPHERE_PASSWORD=VMware1!
VSPHERE_SERVER=api-gateway
VSPHERE_DATACENTER=Datacenter
VSPHERE_DATASTORE=datastore1
VSPHERE_CLUSTER=Cluster
VSPHERE_NETWORK=VM Network
VSPHERE_VM_NAME=web-01
TEST_SMOKE=0
TEST_RUN_ID=
PULUMI_CONFIG_PASSPHRASE=lab
View File
+32
View File
@@ -0,0 +1,32 @@
"""Non-empty output validation for pulumi-vsphere stack results."""
from __future__ import annotations
from typing import Any
def _is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str) and not value.strip():
return True
if isinstance(value, (list, dict, tuple, set)) and len(value) == 0:
return True
return False
def assert_nonempty(outputs: dict[str, Any], *, required: list[str] | None = None) -> list[str]:
"""Return list of error messages for empty/missing outputs."""
errors: list[str] = []
keys = required if required is not None else list(outputs)
if not keys:
errors.append("no outputs exported")
return errors
for key in keys:
if key not in outputs:
errors.append(f"missing output: {key}")
continue
if _is_empty(outputs[key]):
errors.append(f"empty output: {key}={outputs[key]!r}")
return errors
+361
View File
@@ -0,0 +1,361 @@
"""Deep REST CRUD battery: create → read → update → delete with nonempty checks.
Covers session, folder, tagging, content library, and VM where durable handlers exist.
Fails if a deep handler returns a stub marker or empty body after a successful write.
"""
from __future__ import annotations
import json
import secrets
from typing import Any
from rest_matrix import login, request
def _session_headers(session: str) -> dict[str, str]:
return {
"vmware-api-session-id": session,
"Content-Type": "application/json",
"Accept": "application/json",
}
def _parse(body: str) -> Any:
if not body or not body.strip():
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return body
def _is_stub(body: str) -> bool:
return '"stub": true' in body or '"stub":true' in body
def _fail(flow: str, step: str, detail: str) -> dict[str, Any]:
return {"flow": flow, "step": step, "status": "failed", "error": detail}
def _ok(flow: str, detail: str = "") -> dict[str, Any]:
return {"flow": flow, "step": "done", "status": "passed", "error": detail}
def _flow_session(headers: dict[str, str]) -> dict[str, Any]:
# Use a dedicated session so we can delete it without killing the suite session.
sid = login()
h = _session_headers(sid)
code, body = request("GET", "/api/session", headers=h)
if code != 200:
return _fail("session", "get", f"GET /api/session → {code} {body[:120]}")
if _is_stub(body):
return _fail("session", "get", "stub marker on session GET")
code, body = request("DELETE", "/api/session", headers=h)
if code not in {200, 204}:
return _fail("session", "delete", f"DELETE /api/session → {code} {body[:120]}")
code, body = request("GET", "/api/session", headers=h)
if code not in {401, 403, 404}:
return _fail("session", "gone", f"expected auth failure after delete, got {code}")
del headers # suite session untouched
return _ok("session")
def _flow_folder(headers: dict[str, str]) -> dict[str, Any]:
suffix = secrets.token_hex(3)
name = f"crud-folder-{suffix}"
renamed = f"crud-folder-renamed-{suffix}"
code, body = request(
"POST",
"/api/vcenter/folder",
headers=headers,
data=json.dumps({"name": name, "parent": "group-v23"}).encode(),
)
if code not in {200, 201}:
return _fail("folder", "create", f"{code} {body[:160]}")
if _is_stub(body):
return _fail("folder", "create", "stub marker on create")
folder_id = _parse(body)
if not isinstance(folder_id, str) or not folder_id.strip():
return _fail("folder", "create", f"empty folder id: {body[:160]}")
code, body = request("GET", "/api/vcenter/folder", headers=headers)
if code != 200 or _is_stub(body):
return _fail("folder", "list", f"{code} stub={_is_stub(body)} {body[:120]}")
folders = _parse(body) or []
names = {f.get("name") for f in folders if isinstance(f, dict)}
ids = {f.get("folder") for f in folders if isinstance(f, dict)}
if name not in names and folder_id not in ids:
return _fail("folder", "list", f"created folder not in list ({folder_id})")
code, body = request(
"POST",
f"/api/vcenter/folder/{folder_id}?action=rename",
headers=headers,
data=json.dumps({"name": renamed}).encode(),
)
if code not in {200, 204}:
return _fail("folder", "rename", f"{code} {body[:160]}")
code, body = request("GET", "/api/vcenter/folder", headers=headers)
folders = _parse(body) or []
names = {f.get("name") for f in folders if isinstance(f, dict)}
if renamed not in names:
return _fail("folder", "rename-verify", f"renamed name missing: {names}")
code, body = request("DELETE", f"/api/vcenter/folder/{folder_id}", headers=headers)
if code not in {200, 204}:
return _fail("folder", "delete", f"{code} {body[:160]}")
code, body = request("GET", f"/api/vcenter/folder/{folder_id}/children", headers=headers)
if code not in {404, 400}:
# children on missing folder should fail; also check list no longer has it
code2, body2 = request("GET", "/api/vcenter/folder", headers=headers)
folders = _parse(body2) or []
ids = {f.get("folder") for f in folders if isinstance(f, dict)}
if folder_id in ids:
return _fail("folder", "gone", f"folder still listed after delete; children={code}")
return _ok("folder", folder_id)
def _flow_tagging(headers: dict[str, str]) -> dict[str, Any]:
suffix = secrets.token_hex(3)
code, body = request(
"POST",
"/api/cis/tagging/category",
headers=headers,
data=json.dumps(
{
"create_spec": {
"name": f"crud-cat-{suffix}",
"description": "crud",
"cardinality": "MULTIPLE",
"associable_types": ["VirtualMachine"],
}
}
).encode(),
)
if code not in {200, 201}:
return _fail("tagging", "create-category", f"{code} {body[:160]}")
if _is_stub(body):
return _fail("tagging", "create-category", "stub marker")
cat_id = _parse(body)
if not isinstance(cat_id, str) or not cat_id:
return _fail("tagging", "create-category", f"empty id: {body[:120]}")
code, body = request("GET", f"/api/cis/tagging/category/{cat_id}", headers=headers)
if code != 200 or _is_stub(body):
return _fail("tagging", "get-category", f"{code} {body[:160]}")
cat = _parse(body)
if not isinstance(cat, dict) or not cat.get("name"):
return _fail("tagging", "get-category", f"empty category body: {body[:160]}")
code, body = request(
"POST",
"/api/cis/tagging/tag",
headers=headers,
data=json.dumps(
{
"create_spec": {
"name": f"crud-tag-{suffix}",
"category_id": cat_id,
"description": "before",
}
}
).encode(),
)
if code not in {200, 201}:
return _fail("tagging", "create-tag", f"{code} {body[:160]}")
tag_id = _parse(body)
if not isinstance(tag_id, str) or not tag_id:
return _fail("tagging", "create-tag", f"empty tag id: {body[:120]}")
code, body = request("GET", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
if code != 200 or _is_stub(body):
return _fail("tagging", "get-tag", f"{code} {body[:160]}")
tag = _parse(body)
if not isinstance(tag, dict) or tag.get("name") != f"crud-tag-{suffix}":
return _fail("tagging", "get-tag", f"unexpected tag: {body[:160]}")
# No PATCH on tagging in CORE — create/read/delete is the durable contract.
code, body = request("DELETE", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
if code not in {200, 204}:
return _fail("tagging", "delete-tag", f"{code} {body[:160]}")
code, body = request("GET", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
if code not in {404, 400}:
return _fail("tagging", "tag-gone", f"expected 404, got {code}")
code, body = request("DELETE", f"/api/cis/tagging/category/{cat_id}", headers=headers)
if code not in {200, 204}:
return _fail("tagging", "delete-category", f"{code} {body[:160]}")
code, body = request("GET", f"/api/cis/tagging/category/{cat_id}", headers=headers)
if code not in {404, 400}:
return _fail("tagging", "category-gone", f"expected 404, got {code}")
return _ok("tagging", f"{cat_id}/{tag_id}")
def _flow_content_library(headers: dict[str, str]) -> dict[str, Any]:
suffix = secrets.token_hex(3)
code, body = request(
"POST",
"/api/content/local-library",
headers=headers,
data=json.dumps(
{"create_spec": {"name": f"crud-lib-{suffix}", "description": "crud"}}
).encode(),
)
if code not in {200, 201}:
return _fail("content-library", "create-library", f"{code} {body[:160]}")
if _is_stub(body):
return _fail("content-library", "create-library", "stub marker")
lib_id = _parse(body)
if not isinstance(lib_id, str) or not lib_id:
return _fail("content-library", "create-library", f"empty id: {body[:120]}")
code, body = request("GET", "/api/content/library", headers=headers)
if code != 200 or _is_stub(body):
return _fail("content-library", "list-libraries", f"{code} {body[:160]}")
libs = _parse(body) or []
if lib_id not in libs:
return _fail("content-library", "list-libraries", f"{lib_id} not in {libs!r}")
code, body = request(
"POST",
"/api/content/library/item",
headers=headers,
data=json.dumps(
{
"create_spec": {
"library_id": lib_id,
"name": f"crud-item-{suffix}",
"type": "ovf",
"description": "crud-item",
}
}
).encode(),
)
if code not in {200, 201}:
return _fail("content-library", "create-item", f"{code} {body[:160]}")
item_id = _parse(body)
if not isinstance(item_id, str) or not item_id:
return _fail("content-library", "create-item", f"empty item id: {body[:120]}")
code, body = request(
"GET",
f"/api/content/library/item?library_id={lib_id}",
headers=headers,
)
if code != 200 or _is_stub(body):
return _fail("content-library", "list-items", f"{code} {body[:160]}")
items = _parse(body) or []
if item_id not in items:
return _fail("content-library", "list-items", f"{item_id} not in {items!r}")
return _ok("content-library", f"{lib_id}/{item_id}")
def _flow_vm(headers: dict[str, str]) -> dict[str, Any]:
suffix = secrets.token_hex(3)
name = f"crud-vm-{suffix}"
code, body = request(
"POST",
"/api/vcenter/vm",
headers=headers,
data=json.dumps(
{
"name": name,
"placement": {
"folder": "group-v23",
"host": "host-11",
"datastore": "datastore-31",
"resource_pool": "resgroup-22",
},
"cpu_count": 1,
"memory_size_MiB": 512,
}
).encode(),
)
if code not in {200, 201}:
return _fail("vm", "create", f"{code} {body[:160]}")
if _is_stub(body):
return _fail("vm", "create", "stub marker")
vm_id = _parse(body)
if not isinstance(vm_id, str) or not vm_id.startswith("vm-"):
return _fail("vm", "create", f"bad vm id: {body[:120]}")
code, body = request("GET", f"/api/vcenter/vm/{vm_id}", headers=headers)
if code != 200 or _is_stub(body):
return _fail("vm", "get", f"{code} {body[:160]}")
info = _parse(body)
if not isinstance(info, dict) or not info:
return _fail("vm", "get", f"empty vm info: {body[:160]}")
code, body = request(
"PATCH",
f"/api/vcenter/vm/{vm_id}/hardware/cpu",
headers=headers,
data=json.dumps({"count": 2}).encode(),
)
if code not in {200, 204}:
return _fail("vm", "patch-cpu", f"{code} {body[:160]}")
code, body = request("GET", f"/api/vcenter/vm/{vm_id}/hardware/cpu", headers=headers)
if code != 200 or _is_stub(body):
return _fail("vm", "get-cpu", f"{code} {body[:160]}")
cpu = _parse(body)
count = None
if isinstance(cpu, dict):
count = cpu.get("count") or cpu.get("num_cpus") or cpu.get("numCPUs")
if count is not None and int(count) != 2:
return _fail("vm", "patch-verify", f"cpu count={count!r} after patch")
# Ensure powered off before delete.
code, body = request("GET", f"/api/vcenter/vm/{vm_id}/power", headers=headers)
power = _parse(body) if code == 200 else {}
state = ""
if isinstance(power, dict):
state = str(power.get("state") or power.get("power_state") or "")
if state.upper() in {"POWERED_ON", "ON"}:
request(
"POST",
f"/api/vcenter/vm/{vm_id}/power?action=stop",
headers=headers,
data=b"{}",
)
code, body = request("DELETE", f"/api/vcenter/vm/{vm_id}", headers=headers)
if code not in {200, 204}:
return _fail("vm", "delete", f"{code} {body[:160]}")
code, body = request("GET", f"/api/vcenter/vm/{vm_id}", headers=headers)
if code not in {404, 400}:
return _fail("vm", "gone", f"expected 404 after delete, got {code}")
return _ok("vm", vm_id)
def run_rest_crud() -> dict[str, Any]:
"""Run curated deep CRUD flows. Returns suite-ready summary."""
session = login()
headers = _session_headers(session)
flows = [
_flow_session,
_flow_folder,
_flow_tagging,
_flow_content_library,
_flow_vm,
]
results: list[dict[str, Any]] = []
for flow in flows:
# Refresh session in case a prior flow touched auth edges.
if flow is not _flow_session:
session = login()
headers = _session_headers(session)
results.append(flow(headers))
failed = [r for r in results if r.get("status") == "failed"]
return {
"flows": results,
"total": len(results),
"failed": len(failed),
"failures": failed,
"ok": not failed,
}
+497
View File
@@ -0,0 +1,497 @@
"""Full REST verb×path×majors matrix for pulumi-tests (hybrid suite).
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.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from base64 import b64encode
from collections import Counter
from typing import Any
from urllib.parse import urlencode
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major, methods_for_major
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED
_PATH_SUBS = {
"{vm}": "vm-101",
"{host}": "host-11",
"{datastore}": "datastore-31",
"{task}": "task-1",
"{snapshot}": "snapshot-missing",
"{category_id}": "cat-lab-1",
"{tag_id}": "tag-lab-1",
"{item_id}": "item-ubuntu",
"{library_id}": "lib-local-1",
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
"{permission_id}": "999999",
"{policy}": "policy-default",
"{disk}": "2000",
"{nic}": "4000",
"{cdrom}": "3000",
"{floppy}": "8000",
"{port}": "9000",
"{adapter}": "1000",
"{provider}": "vsphere.local",
"{supervisor}": "supervisor-1",
"{namespace}": "ns-lab-1",
"{role}": "ReadOnly",
"{zone}": "zone-1",
"{project}": "project-1",
"{domain}": "lab.local",
"{service}": "vsphere-ui",
"{depot}": "depot-1",
"{component}": "component-1",
"{image}": "image-1",
"{draft}": "draft-1",
"{connection}": "connection-1",
"{vpc}": "vpc-1",
"{subnet}": "subnet-1",
"{session_id}": "session-lab-1",
"{download_session_id}": "session-lab-1",
"{update_session_id}": "session-lab-1",
"{subscription_id}": "sub-1",
"{usage_id}": "usage-1",
"{version}": "1",
"{chain}": "chain-1",
"{node}": "node-1",
"{profile}": "profile-1",
"{interface}": "nic0",
"{core}": "core-1",
"{network}": "network-41",
"{commit}": "commit-lab-1",
}
_ACCEPT_CLIENT = {400, 401, 403, 404, 405, 409, 412, 422}
_INVENTORY_CRITICAL = {
"/api/vcenter/vm",
"/api/vcenter/host",
"/api/vcenter/datastore",
"/api/vcenter/network",
"/api/vcenter/cluster",
"/api/cis/tagging/category",
"/api/content/library",
"/api/esx/settings/clusters/{cluster}/software",
"/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
"/api/appliance/access/ssh",
"/api/appliance/services",
}
def _base() -> str:
explicit = os.environ.get("VSPHERE_BASE")
if explicit:
return explicit.rstrip("/")
server = os.environ.get("VSPHERE_SERVER", "localhost")
if server.startswith("http://") or server.startswith("https://"):
return server.rstrip("/")
return f"https://{server}"
def _creds() -> tuple[str, str]:
return (
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
)
def _ctx() -> ssl.SSLContext | None:
if not _base().startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def concrete_path(path: str) -> str:
out = path
for key, value in _PATH_SUBS.items():
out = out.replace(key, value)
return re.sub(r"\{([A-Za-z0-9_]+)\}", r"probe-\1", out)
def request(
method: str,
path: str,
*,
headers: dict[str, str],
data: bytes | None = None,
) -> tuple[int, str]:
# 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}"
else:
url = f"{_base()}{concrete_path(path)}"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
body = resp.read().decode("utf-8", errors="replace")
return int(resp.status), body
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
return int(error.code), body
def login() -> str:
user, password = _creds()
basic = b64encode(f"{user}:{password}".encode()).decode()
code, body = request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
if code not in {200, 201}:
raise RuntimeError(f"session failed: {code} {body[:200]}")
return json.loads(body)
def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
if verb not in {"POST", "PUT", "PATCH"}:
return path, None
if path.endswith("/power") and verb == "POST":
if "/guest/power" in path:
return f"{path}?action=reboot", b"{}"
return f"{path}?action=start", b"{}"
if path.endswith("/maintenance") and verb == "POST":
return f"{path}?action=enter", b"{}"
if path.endswith("/folder/{folder}") and verb == "POST":
return f"{path}?action=rename", json.dumps({"name": "folder-renamed-probe"}).encode()
suffix = secrets.token_hex(4)
bodies: dict[str, dict[str, Any]] = {
"/api/vcenter/vm": {
"name": f"matrix-probe-vm-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
"cpu_count": 1,
"memory_size_MiB": 512,
},
"/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}"},
"/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}"},
"/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/network/dvs": {"name": f"probe-dvs-{suffix}"},
"/api/vcenter/network/dvpg": {
"name": f"probe-dvpg-{suffix}",
"dvs": "dvs-51",
"vlan_id": 10,
},
"/api/cis/tagging/category": {
"create_spec": {
"name": f"probe-cat-{suffix}",
"description": "probe",
"cardinality": "MULTIPLE",
"associable_types": [],
}
},
"/api/cis/tagging/tag": {
"create_spec": {
"name": f"probe-tag-{suffix}",
"category_id": "missing-category",
"description": "x",
}
},
"/api/cis/tagging/tag-association": {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
},
"/api/content/local-library": {"create_spec": {"name": f"probe-lib-{suffix}"}},
"/api/content/library/item": {
"create_spec": {
"library_id": "lib-missing",
"name": f"probe-item-{suffix}",
"type": "ovf",
}
},
"/api/vcenter/ovf/library-item/{item_id}": {
"deployment_spec": {"name": f"ovf-probe-{suffix}"},
"target": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
},
"/api/vcenter/authorization/permissions": {
"principal": "readonly@vsphere.local",
"role": "ReadOnly",
"entity": "datacenter-21",
},
"/api/vcenter/datastore/{datastore}/files": {
"path": f"/probe-{suffix}.txt",
"size": 1,
"type": "FILE",
},
"/api/vcenter/vm/{vm}/hardware/cpu": {"count": 2},
"/api/vcenter/vm/{vm}/hardware/memory": {"size_MiB": 1024},
"/api/vcenter/vm/{vm}/hardware/disk": {"type": "SCSI", "new_vmdk": {"capacity": 1024}},
"/api/vcenter/vm/{vm}/hardware/ethernet": {
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
},
"/api/vcenter/vm/{vm}/snapshots": {"name": f"probe-snap-{suffix}"},
"/api/vcenter/vm/{vm}/snapshots/{snapshot}": {"action": "revert"},
"/api/vcenter/vm/{vm}/clone": {
"name": f"probe-clone-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11"},
},
"/api/vcenter/vm/{vm}/relocate": {"placement": {"host": "host-12"}},
"/api/vcenter/vm/{vm}/tools": {"action": "upgrade"},
"/api/vcenter/vm/{vm}/console/tickets": {"type": "WEBMKS"},
"/api/vcenter/vm/{vm}/guest/customization": {"name": {"name": f"guest-probe-{suffix}"}},
"/api/vcenter/vm/{vm}": {"action": "unregister"},
}
body = bodies.get(path, {})
return path, json.dumps(body).encode()
def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
params = urlencode({"major": major})
code, body = request(
"POST",
f"/ui/api/contract/apply?{params}",
headers=headers,
)
if code >= 400:
raise RuntimeError(f"contract apply major={major} failed: {code} {body[:200]}")
return json.loads(body)
def _classify(verb: str, path: str) -> str:
status = CORE_IMPLEMENTED.get((verb, path)) or IMPLEMENTED.get((verb, path))
if (verb, path) in CORE_IMPLEMENTED:
return "deep"
if status == "stub":
return "stub"
return "deep" if status == "implemented" else "unknown"
def _record_result(
*,
major: int,
verb: str,
path: str,
code: int,
body: str,
buckets: Counter[str],
failures: list[dict[str, Any]],
deep_stub: Counter[str],
) -> None:
kind = _classify(verb, path)
deep_stub[kind] += 1
if 200 <= code < 300:
buckets["success_2xx"] += 1
if kind == "stub":
buckets["stub_ok"] += 1
if major == 9 and verb == "GET" and body:
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:
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],
}
)
def probe_major(major: int, session: str) -> dict[str, Any]:
headers = {
"vmware-api-session-id": session,
"Content-Type": "application/json",
"Accept": "application/json",
}
applied = apply_major(major, headers)
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"]),
)
buckets: Counter[str] = Counter()
deep_stub: Counter[str] = Counter()
failures: list[dict[str, Any]] = []
probed = 0
by_verb: Counter[str] = Counter()
for entry in entries:
verb = entry["verb"]
path = entry["path"]
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}",
}:
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")
code, body = request(verb, url_path, headers=headers)
else:
url_path, data = _payload_for(verb, path)
if verb == "GET" and path == "/api/content/library/item":
url_path = f"{url_path}?library_id=lib-local-1"
code, body = request(verb, url_path, headers=headers, data=data)
probed += 1
_record_result(
major=major,
verb=verb,
path=path,
code=code,
body=body,
buckets=buckets,
failures=failures,
deep_stub=deep_stub,
)
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,
)
return {
"major": major,
"version": applied.get("runtime_version"),
"method_count": len(entries),
"by_verb": dict(by_verb),
"probed": probed,
"above_floor_checked": above_floor,
"buckets": dict(buckets),
"deep_vs_stub": dict(deep_stub),
"failures": failures,
"failed": len(failures),
}
def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
"""Probe IMPLEMENTED × majors. Returns summary suitable for suite JSON/HTML."""
if majors is None:
majors = [6, 7, 8, 9]
for major in majors:
if major not in VERSIONS:
raise ValueError(f"unknown major {major}")
session = login()
reports: list[dict[str, Any]] = []
all_failures: list[dict[str, Any]] = []
verb_totals: Counter[str] = Counter()
for major in majors:
report = probe_major(major, session)
reports.append(report)
all_failures.extend(report["failures"])
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)
return {
"base": _base(),
"majors": reports,
"by_verb": dict(verb_totals),
"total": total,
"failed": failed,
"failures": all_failures[:120],
"ok": failed == 0,
}
+648
View File
@@ -0,0 +1,648 @@
"""SOAP WSDL ops probe for pulumi-tests hybrid suite.
Covers every operation advertised in /sdk/vimService.wsdl (same list as
app/vsphere/soap/router.py). Fail on HTTP 5xx. Create/Power/Clone/Reconfig/Destroy
ops additionally assert task return + inventory side-effects via FindByInventoryPath
or RetrieveProperties where applicable.
"""
from __future__ import annotations
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from typing import Any
from xml.sax.saxutils import escape
# Keep in sync with app/vsphere/soap/router.py sdk_wsdl ops list.
WSDL_OPS: list[str] = [
"RetrieveServiceContent",
"Login",
"Logout",
"RetrieveProperties",
"RetrievePropertiesEx",
"ContinueRetrievePropertiesEx",
"CreateFilter",
"WaitForUpdatesEx",
"CreateContainerView",
"DestroyPropertyFilter",
"FindByInventoryPath",
"FindByUuid",
"FindByDnsName",
"FindByIp",
"FindChild",
"CreateVM_Task",
"CreateChildVM_Task",
"CreateFolder",
"PowerOnVM_Task",
"PowerOffVM_Task",
"CloneVM_Task",
"CreateSnapshot_Task",
"Rename_Task",
"ReconfigVM_Task",
"RelocateVM_Task",
"Destroy_Task",
"CustomizeVM_Task",
"CancelTask",
"CurrentTime",
"InitiateFileTransferToGuest",
"InitiateFileTransferFromGuest",
"ListFilesInGuest",
"DeleteFileInGuest",
"MakeDirectoryInGuest",
"ImportVApp_Task",
"CreateImportSpec",
"HttpNfcLeaseComplete",
"HttpNfcLeaseProgress",
"HttpNfcLeaseAbort",
"HttpNfcLeaseGetManifest",
"QueryConfigOption",
"QueryConfigOptionEx",
"QueryConfigOptionDescriptor",
"QueryConfigTarget",
]
def _base() -> str:
explicit = os.environ.get("VSPHERE_BASE")
if explicit:
return explicit.rstrip("/")
server = os.environ.get("VSPHERE_SERVER", "localhost")
if server.startswith("http://") or server.startswith("https://"):
return server.rstrip("/")
return f"https://{server}"
def _creds() -> tuple[str, str]:
return (
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
)
def _ctx() -> ssl.SSLContext | None:
if not _base().startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def _envelope(inner: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8"?>'
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
' xmlns:urn="urn:vim25">'
f"<soapenv:Body>{inner}</soapenv:Body>"
"</soapenv:Envelope>"
)
def _post(
body: str, *, cookie: str | None = None, session_id: str | None = None
) -> tuple[int, str, dict[str, str]]:
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '""',
}
if cookie:
headers["Cookie"] = cookie
if session_id:
headers["vmware-api-session-id"] = session_id
req = urllib.request.Request(
f"{_base()}/sdk",
data=body.encode(),
method="POST",
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), raw, {k.lower(): v for k, v in resp.headers.items()}
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8", errors="replace")
return int(error.code), raw, {k.lower(): v for k, v in error.headers.items()}
def _xml_text(body: str, tag: str) -> str | None:
match = re.search(rf"<(?:\w+:)?{re.escape(tag)}[^>]*>([^<]*)</(?:\w+:)?{re.escape(tag)}>", body)
return match.group(1) if match else None
def _task_id(body: str) -> str | None:
match = re.search(r'type="Task">([^<]+)<', body) or re.search(r">(task-[^<]+)<", body)
return match.group(1) if match else None
def _login() -> tuple[str, str]:
user, password = _creds()
code, body, headers = _post(
_envelope(
"<urn:Login>"
'<urn:_this type="SessionManager">SessionManager</urn:_this>'
f"<urn:userName>{escape(user)}</urn:userName>"
f"<urn:password>{escape(password)}</urn:password>"
"</urn:Login>"
)
)
if code >= 500:
raise RuntimeError(f"SOAP Login 5xx: {code} {body[:200]}")
if code >= 400:
raise RuntimeError(f"SOAP Login failed: {code} {body[:200]}")
set_cookie = headers.get("set-cookie") or ""
cookie = set_cookie.split(";")[0] if set_cookie else ""
session_id = headers.get("vmware-api-session-id") or ""
if "vmware_soap_session" in set_cookie and not cookie.startswith("vmware_soap_session"):
# normalize
for part in set_cookie.split(","):
part = part.strip()
if part.startswith("vmware_soap_session"):
cookie = part.split(";")[0]
break
if not cookie and session_id:
cookie = f'vmware_soap_session="{session_id}"'
if not cookie and not session_id:
# body may still indicate success — use header-less cookie from LoginResponse key
key = _xml_text(body, "key")
if key:
cookie = f'vmware_soap_session="{key}"'
session_id = key
if not cookie and not session_id:
raise RuntimeError(f"SOAP Login missing session: {body[:200]}")
return cookie, session_id
def _op_body(op: str, *, suffix: str, lab_vm: str = "vm-101") -> str:
"""Minimal SOAP body for each WSDL op."""
if op == "RetrieveServiceContent":
return (
"<urn:RetrieveServiceContent>"
'<urn:_this type="ServiceInstance">ServiceInstance</urn:_this>'
"</urn:RetrieveServiceContent>"
)
if op == "Login":
user, password = _creds()
return (
"<urn:Login>"
'<urn:_this type="SessionManager">SessionManager</urn:_this>'
f"<urn:userName>{escape(user)}</urn:userName>"
f"<urn:password>{escape(password)}</urn:password>"
"</urn:Login>"
)
if op == "Logout":
return (
'<urn:Logout><urn:_this type="SessionManager">SessionManager</urn:_this></urn:Logout>'
)
if op in {"RetrieveProperties", "RetrievePropertiesEx"}:
return (
f"<urn:{op}>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:specSet>"
"<urn:propSet><urn:type>VirtualMachine</urn:type><urn:pathSet>name</urn:pathSet></urn:propSet>"
'<urn:objectSet><urn:obj type="VirtualMachine">vm-101</urn:obj></urn:objectSet>'
"</urn:specSet>"
f"</urn:{op}>"
)
if op == "ContinueRetrievePropertiesEx":
return (
"<urn:ContinueRetrievePropertiesEx>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:token>token-none</urn:token>"
"</urn:ContinueRetrievePropertiesEx>"
)
if op == "CreateFilter":
return (
"<urn:CreateFilter>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:spec>"
"<urn:propSet><urn:type>Folder</urn:type><urn:all>true</urn:all></urn:propSet>"
'<urn:objectSet><urn:obj type="Folder">group-d1</urn:obj></urn:objectSet>'
"</urn:spec>"
"<urn:partialUpdates>false</urn:partialUpdates>"
"</urn:CreateFilter>"
)
if op == "WaitForUpdatesEx":
return (
"<urn:WaitForUpdatesEx>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:version></urn:version>"
"</urn:WaitForUpdatesEx>"
)
if op == "CreateContainerView":
return (
"<urn:CreateContainerView>"
'<urn:_this type="ViewManager">ViewManager</urn:_this>'
'<urn:container type="Folder">group-d1</urn:container>'
"<urn:type>VirtualMachine</urn:type>"
"<urn:recursive>true</urn:recursive>"
"</urn:CreateContainerView>"
)
if op == "DestroyPropertyFilter":
return (
"<urn:DestroyPropertyFilter>"
'<urn:_this type="PropertyFilter">filter-1</urn:_this>'
"</urn:DestroyPropertyFilter>"
)
if op == "FindByInventoryPath":
return (
"<urn:FindByInventoryPath>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:inventoryPath>/Datacenter/vm/web-01</urn:inventoryPath>"
"</urn:FindByInventoryPath>"
)
if op == "FindByUuid":
return (
"<urn:FindByUuid>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:uuid>00000000-0000-0000-0000-000000000000</urn:uuid>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByUuid>"
)
if op == "FindByDnsName":
return (
"<urn:FindByDnsName>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:dnsName>web-01.lab.local</urn:dnsName>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByDnsName>"
)
if op == "FindByIp":
return (
"<urn:FindByIp>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:ip>10.0.0.10</urn:ip>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByIp>"
)
if op == "FindChild":
return (
"<urn:FindChild>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
'<urn:entity type="Folder">group-v23</urn:entity>'
"<urn:name>web-01</urn:name>"
"</urn:FindChild>"
)
if op == "CreateVM_Task":
return (
"<urn:CreateVM_Task>"
'<urn:_this type="Folder">group-v23</urn:_this>'
"<urn:config>"
f"<urn:name>soap-create-{suffix}</urn:name>"
"<urn:guestId>otherGuest64</urn:guestId>"
"<urn:numCPUs>1</urn:numCPUs>"
"<urn:memoryMB>512</urn:memoryMB>"
"<urn:files><urn:vmPathName>[datastore1]</urn:vmPathName></urn:files>"
"</urn:config>"
'<urn:pool type="ResourcePool">resgroup-22</urn:pool>'
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:CreateVM_Task>"
)
if op == "CreateChildVM_Task":
return (
"<urn:CreateChildVM_Task>"
'<urn:_this type="ResourcePool">resgroup-22</urn:_this>'
"<urn:config>"
f"<urn:name>soap-child-{suffix}</urn:name>"
"<urn:guestId>otherGuest64</urn:guestId>"
"<urn:numCPUs>1</urn:numCPUs>"
"<urn:memoryMB>512</urn:memoryMB>"
"<urn:files><urn:vmPathName>[datastore1]</urn:vmPathName></urn:files>"
"</urn:config>"
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:CreateChildVM_Task>"
)
if op == "CreateFolder":
return (
"<urn:CreateFolder>"
'<urn:_this type="Folder">group-v23</urn:_this>'
f"<urn:name>soap-folder-{suffix}</urn:name>"
"</urn:CreateFolder>"
)
if op == "PowerOnVM_Task":
return (
"<urn:PowerOnVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:PowerOnVM_Task>"
)
if op == "PowerOffVM_Task":
return (
"<urn:PowerOffVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:PowerOffVM_Task>"
)
if op == "CloneVM_Task":
return (
"<urn:CloneVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
'<urn:folder type="Folder">group-v23</urn:folder>'
f"<urn:name>soap-clone-{suffix}</urn:name>"
"<urn:spec><urn:powerOn>false</urn:powerOn><urn:template>false</urn:template></urn:spec>"
"</urn:CloneVM_Task>"
)
if op == "CreateSnapshot_Task":
return (
"<urn:CreateSnapshot_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
f"<urn:name>soap-snap-{suffix}</urn:name>"
"<urn:description>probe</urn:description>"
"<urn:memory>false</urn:memory>"
"<urn:quiesce>false</urn:quiesce>"
"</urn:CreateSnapshot_Task>"
)
if op == "Rename_Task":
return (
"<urn:Rename_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
f"<urn:newName>web-01-renamed-{suffix}</urn:newName>"
"</urn:Rename_Task>"
)
if op == "ReconfigVM_Task":
return (
"<urn:ReconfigVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec><urn:numCPUs>2</urn:numCPUs></urn:spec>"
"</urn:ReconfigVM_Task>"
)
if op == "RelocateVM_Task":
return (
"<urn:RelocateVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec>"
'<urn:host type="HostSystem">host-11</urn:host>'
'<urn:datastore type="Datastore">datastore-31</urn:datastore>'
"</urn:spec>"
"</urn:RelocateVM_Task>"
)
if op == "Destroy_Task":
return (
"<urn:Destroy_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:Destroy_Task>"
)
if op == "CustomizeVM_Task":
return (
"<urn:CustomizeVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec><urn:identity/></urn:spec>"
"</urn:CustomizeVM_Task>"
)
if op == "CancelTask":
return '<urn:CancelTask><urn:_this type="Task">task-1</urn:_this></urn:CancelTask>'
if op == "CurrentTime":
return (
"<urn:CurrentTime>"
'<urn:_this type="ServiceInstance">ServiceInstance</urn:_this>'
"</urn:CurrentTime>"
)
if op in {
"InitiateFileTransferToGuest",
"InitiateFileTransferFromGuest",
"ListFilesInGuest",
"DeleteFileInGuest",
"MakeDirectoryInGuest",
}:
return (
f"<urn:{op}>"
f'<urn:_this type="GuestFileManager">guestFileManager-{lab_vm}</urn:_this>'
f'<urn:vm type="VirtualMachine">{lab_vm}</urn:vm>'
"<urn:auth><urn:username>root</urn:username><urn:password>lab</urn:password></urn:auth>"
f"<urn:filePath>/tmp/soap-{suffix}</urn:filePath>"
f"</urn:{op}>"
)
if op == "ImportVApp_Task":
return (
"<urn:ImportVApp_Task>"
'<urn:_this type="ResourcePool">resgroup-22</urn:_this>'
"<urn:spec/>"
'<urn:folder type="Folder">group-v23</urn:folder>'
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:ImportVApp_Task>"
)
if op == "CreateImportSpec":
return (
"<urn:CreateImportSpec>"
'<urn:_this type="OvfManager">OvfManager</urn:_this>'
"<urn:ovfDescriptor>unused</urn:ovfDescriptor>"
'<urn:resourcePool type="ResourcePool">resgroup-22</urn:resourcePool>'
'<urn:datastore type="Datastore">datastore-31</urn:datastore>'
"</urn:CreateImportSpec>"
)
if op in {
"HttpNfcLeaseComplete",
"HttpNfcLeaseProgress",
"HttpNfcLeaseAbort",
"HttpNfcLeaseGetManifest",
}:
return (
f"<urn:{op}>"
'<urn:_this type="HttpNfcLease">lease-1</urn:_this>'
"<urn:percent>100</urn:percent>"
f"</urn:{op}>"
)
if op in {
"QueryConfigOption",
"QueryConfigOptionEx",
"QueryConfigOptionDescriptor",
"QueryConfigTarget",
}:
return f'<urn:{op}><urn:_this type="EnvironmentBrowser">envbrowser-1</urn:_this></urn:{op}>'
return f'<urn:{op}><urn:_this type="ServiceInstance">ServiceInstance</urn:_this></urn:{op}>'
def _find_by_path(cookie: str, session_id: str, path: str) -> tuple[int, str]:
code, body, _ = _post(
_envelope(
"<urn:FindByInventoryPath>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
f"<urn:inventoryPath>{escape(path)}</urn:inventoryPath>"
"</urn:FindByInventoryPath>"
),
cookie=cookie,
session_id=session_id,
)
return code, body
def _verify_side_effect(
op: str,
response: str,
*,
cookie: str,
session_id: str,
suffix: str,
) -> str | None:
"""Return error string if side-effect check fails; None if ok/not applicable."""
if op in {
"CreateVM_Task",
"CreateChildVM_Task",
"CloneVM_Task",
"PowerOnVM_Task",
"PowerOffVM_Task",
"Destroy_Task",
"ReconfigVM_Task",
"CreateFolder",
}:
if op.endswith("_Task") and not _task_id(response) and "Task" not in response:
return "missing Task returnval"
if op in {"CreateVM_Task", "CreateChildVM_Task"}:
name = f"soap-create-{suffix}" if op == "CreateVM_Task" else f"soap-child-{suffix}"
code, body = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
if code >= 500:
return f"FindByInventoryPath 5xx after {op}"
if "VirtualMachine" not in body and name not in body:
# Some seeds place under production folder — also try that path.
code2, body2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
if "VirtualMachine" not in body2 and name not in body2:
return f"created VM {name} not found in inventory"
if op == "CreateFolder":
folder_moid = _xml_text(response, "returnval")
if not folder_moid:
return "CreateFolder missing Folder returnval"
if op == "CloneVM_Task":
name = f"soap-clone-{suffix}"
code, body = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
if code >= 500:
return f"FindByInventoryPath 5xx after clone"
if "VirtualMachine" not in body and name not in body:
code2, body2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
if "VirtualMachine" not in body2 and name not in body2:
return f"clone {name} not found"
if op == "Destroy_Task":
# Destroy uses a disposable VM created earlier in the suite — checked by caller via lab_vm.
pass
return None
def run_soap_ops() -> dict[str, Any]:
"""Exercise all WSDL SOAP ops. Mutating ops use disposable VMs where needed."""
cookie, session_id = _login()
results: list[dict[str, Any]] = []
failures: list[dict[str, Any]] = []
# Disposable VM for destroy / power cycles (do not destroy seeded web-01).
suffix = secrets.token_hex(3)
create_body = _envelope(_op_body("CreateVM_Task", suffix=f"lab-{suffix}"))
code, body, _ = _post(create_body, cookie=cookie, session_id=session_id)
disposable_vm = "vm-101"
if code < 500 and _task_id(body):
# Resolve created VM name via FindByInventoryPath
name = f"soap-create-lab-{suffix}"
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if moid:
disposable_vm = moid.group(1)
else:
_, found2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found2)
if moid:
disposable_vm = moid.group(1)
# Prefer a clone as destroy target so we never delete the only disposable if create failed.
clone_suffix = secrets.token_hex(3)
clone_body = _envelope(_op_body("CloneVM_Task", suffix=clone_suffix, lab_vm=disposable_vm))
code, body, _ = _post(clone_body, cookie=cookie, session_id=session_id)
destroy_target = disposable_vm
if code < 500:
cname = f"soap-clone-{clone_suffix}"
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/{cname}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if not moid:
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{cname}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if moid:
destroy_target = moid.group(1)
for op in WSDL_OPS:
op_suffix = secrets.token_hex(3)
lab_vm = destroy_target if op == "Destroy_Task" else disposable_vm
# Avoid Logout killing the suite session mid-run — probe with a fresh login at end.
if op == "Logout":
tmp_cookie, tmp_sid = _login()
code, body, _ = _post(
_envelope(_op_body(op, suffix=op_suffix, lab_vm=lab_vm)),
cookie=tmp_cookie,
session_id=tmp_sid,
)
elif op == "Login":
code, body, _ = _post(_envelope(_op_body(op, suffix=op_suffix)))
elif op == "Rename_Task":
# Rename disposable VM then rename back via another call is heavy; use folder instead.
folder_code, folder_body, _ = _post(
_envelope(_op_body("CreateFolder", suffix=f"rn-{op_suffix}")),
cookie=cookie,
session_id=session_id,
)
folder_id = _xml_text(folder_body, "returnval") or "group-v23"
if folder_code >= 500:
code, body = folder_code, folder_body
else:
code, body, _ = _post(
_envelope(
"<urn:Rename_Task>"
f'<urn:_this type="Folder">{folder_id}</urn:_this>'
f"<urn:newName>soap-renamed-{op_suffix}</urn:newName>"
"</urn:Rename_Task>"
),
cookie=cookie,
session_id=session_id,
)
else:
code, body, _ = _post(
_envelope(_op_body(op, suffix=op_suffix, lab_vm=lab_vm)),
cookie=cookie,
session_id=session_id,
)
entry: dict[str, Any] = {
"op": op,
"status": code,
"ok": True,
"error": "",
}
if code >= 500:
entry["ok"] = False
entry["error"] = f"HTTP {code}: {body[:200]}"
else:
side = _verify_side_effect(
op,
body,
cookie=cookie,
session_id=session_id,
suffix=op_suffix if op != "CreateVM_Task" else op_suffix,
)
# CreateVM_Task in the loop creates yet another VM — verify with its suffix.
if (
op in {"CreateVM_Task", "CreateChildVM_Task", "CloneVM_Task", "CreateFolder"}
and side
):
entry["ok"] = False
entry["error"] = side
elif op in {"PowerOnVM_Task", "PowerOffVM_Task", "ReconfigVM_Task", "Destroy_Task"}:
if not _task_id(body) and "Task" not in body and "Response" not in body:
entry["ok"] = False
entry["error"] = "missing task/response"
elif op == "Destroy_Task":
# Confirm target is gone
_, found = _find_by_path(
cookie, session_id, f"/Datacenter/vm/soap-clone-{clone_suffix}"
)
if 'type="VirtualMachine"' in found and destroy_target in found:
entry["ok"] = False
entry["error"] = "VM still present after Destroy_Task"
if not entry["ok"]:
failures.append(entry)
results.append(entry)
return {
"ops": results,
"total": len(results),
"failed": len(failures),
"failures": failures,
"ok": not failures,
"wsdl_ops": len(WSDL_OPS),
}
@@ -0,0 +1,6 @@
name: pulumi-tests-folders
runtime:
name: python
options:
virtualenv: .venv
description: Create VM folder via pulumi-vsphere
+40
View File
@@ -0,0 +1,40 @@
"""Create a VM folder with pulumi-vsphere Folder."""
from __future__ import annotations
import os
import uuid
import pulumi
import pulumi_vsphere as vsphere
user = os.environ.get("VSPHERE_USER", "administrator@vsphere.local")
password = os.environ.get("VSPHERE_PASSWORD", "VMware1!")
server = os.environ.get("VSPHERE_SERVER", "api-gateway")
datacenter_name = os.environ.get("VSPHERE_DATACENTER", "Datacenter")
run_id = os.environ.get("TEST_RUN_ID") or uuid.uuid4().hex[:8]
folder_name = os.environ.get("VSPHERE_LAB_FOLDER", f"pulumi-folder-{run_id}")
provider = vsphere.Provider(
"vsphere",
user=user,
password=password,
vsphere_server=server,
allow_unverified_ssl=True,
)
invoke_opts = pulumi.InvokeOptions(provider=provider)
res_opts = pulumi.ResourceOptions(provider=provider)
dc = vsphere.get_datacenter_output(name=datacenter_name, opts=invoke_opts)
folder = vsphere.Folder(
"lab-folder",
path=folder_name,
type="vm",
datacenter_id=dc.id,
opts=res_opts,
)
pulumi.export("folder_id", folder.id)
pulumi.export("folder_path", folder.path)
pulumi.export("datacenter_id", dc.id)
@@ -0,0 +1,2 @@
pulumi>=3.0.0,<4
pulumi-vsphere==4.17.0
@@ -0,0 +1,3 @@
name: vsphere-inventory
runtime: python
description: pulumi-vsphere inventory data sources against the lab simulator
@@ -0,0 +1,81 @@
"""Inventory lookups via official pulumi-vsphere (SOAP/VIM)."""
from __future__ import annotations
import os
import pulumi
import pulumi_vsphere as vsphere
user = os.environ.get("VSPHERE_USER", "administrator@vsphere.local")
password = os.environ.get("VSPHERE_PASSWORD", "VMware1!")
server = os.environ.get("VSPHERE_SERVER", "api-gateway")
datacenter_name = os.environ.get("VSPHERE_DATACENTER", "Datacenter")
datastore_name = os.environ.get("VSPHERE_DATASTORE", "datastore1")
cluster_name = os.environ.get("VSPHERE_CLUSTER", "Cluster")
network_name = os.environ.get("VSPHERE_NETWORK", "VM Network")
vm_name = os.environ.get("VSPHERE_VM_NAME", "web-01")
host_name = os.environ.get("VSPHERE_HOST", "esxi01.lab.local")
folder_path = os.environ.get("VSPHERE_FOLDER_PATH", f"/{datacenter_name}/vm/production")
pool_name = os.environ.get("VSPHERE_RESOURCE_POOL", "Resources")
provider = vsphere.Provider(
"vsphere",
user=user,
password=password,
vsphere_server=server,
allow_unverified_ssl=True,
)
invoke_opts = pulumi.InvokeOptions(provider=provider)
dc = vsphere.get_datacenter_output(name=datacenter_name, opts=invoke_opts)
ds = vsphere.get_datastore_output(
name=datastore_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
cluster = vsphere.get_compute_cluster_output(
name=cluster_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
network = vsphere.get_network_output(
name=network_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
vm = vsphere.get_virtual_machine_output(
name=vm_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
host = vsphere.get_host_output(
name=host_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
folder = vsphere.get_folder_output(path=folder_path, opts=invoke_opts)
pool = vsphere.get_resource_pool_output(
name=pool_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
pulumi.export("datacenter_id", dc.id)
pulumi.export("datacenter_name", dc.name)
pulumi.export("datastore_id", ds.id)
pulumi.export("datastore_name", ds.name)
pulumi.export("cluster_id", cluster.id)
pulumi.export("cluster_name", cluster.name)
pulumi.export("resource_pool_id", pool.id)
pulumi.export("resource_pool_name", pool.name)
pulumi.export("cluster_resource_pool_id", cluster.resource_pool_id)
pulumi.export("network_id", network.id)
pulumi.export("network_name", network.name)
pulumi.export("vm_id", vm.id)
pulumi.export("vm_name", vm.name)
pulumi.export("host_id", host.id)
pulumi.export("host_name", host.name)
pulumi.export("folder_id", folder.id)
pulumi.export("folder_path", folder.path)
@@ -0,0 +1,2 @@
pulumi>=3.120,<4
pulumi-vsphere==4.17.0
+3
View File
@@ -0,0 +1,3 @@
name: vsphere-tags
runtime: python
description: pulumi-vsphere TagCategory + Tag against the lab simulator
+45
View File
@@ -0,0 +1,45 @@
"""Tag category + tag via pulumi-vsphere."""
from __future__ import annotations
import os
import uuid
import pulumi
import pulumi_vsphere as vsphere
user = os.environ.get("VSPHERE_USER", "administrator@vsphere.local")
password = os.environ.get("VSPHERE_PASSWORD", "VMware1!")
server = os.environ.get("VSPHERE_SERVER", "api-gateway")
run_id = os.environ.get("TEST_RUN_ID") or uuid.uuid4().hex[:8]
provider = vsphere.Provider(
"vsphere",
user=user,
password=password,
vsphere_server=server,
allow_unverified_ssl=True,
)
opts = pulumi.ResourceOptions(provider=provider)
category = vsphere.TagCategory(
"lab-category",
name=f"pulumi-cat-{run_id}",
description="pulumi-tests",
cardinality="MULTIPLE",
associable_types=["VirtualMachine"],
opts=opts,
)
tag = vsphere.Tag(
"lab-tag",
name=f"pulumi-tag-{run_id}",
description="pulumi-tests",
category_id=category.id,
opts=opts,
)
pulumi.export("category_id", category.id)
pulumi.export("category_name", category.name)
pulumi.export("tag_id", tag.id)
pulumi.export("tag_name", tag.name)
@@ -0,0 +1,2 @@
pulumi>=3.120,<4
pulumi-vsphere==4.17.0
@@ -0,0 +1 @@
encryptionsalt: v1:UUjhYFprJyY=:v1:an60sL6CLXStUIcl:mJhUQXmFtkRGQyBEokvqKfyRrAfKrA==
@@ -0,0 +1 @@
encryptionsalt: v1:LVYgzGHt9FE=:v1:eSMxx80CDF7fdjC7:vjS6R4g5y17oCo7Zla2RVreTGVt5Ew==
@@ -0,0 +1 @@
encryptionsalt: v1:IF5v/IbhjaY=:v1:LMDcKVXc3t3kK9c8:i7oLeO3Z68leNZMNWy8p4ABQXtU/MA==
@@ -0,0 +1 @@
encryptionsalt: v1:o52eDGvINSY=:v1:FNi0o3DBgs7EGXOK:DvcytDQKTUTZJlzP5FHpmO4Tz5x0lA==
@@ -0,0 +1 @@
encryptionsalt: v1:T3Ad5ENWpbA=:v1:HPpPGCMDQLdXR4Rd:IoE+7gigbnLsdamOgHQQ/Mjz7OZoKA==
@@ -0,0 +1 @@
encryptionsalt: v1:OJmTQsPHPwc=:v1:t9AexCKmbXhXOznA:IcfsNrOKNTSCgrh71oUi1PE2aXQ0lg==
@@ -0,0 +1 @@
encryptionsalt: v1:qwT7qp8s1FE=:v1:6yP9NvJX8W1ii/42:Ttz0zuFgzXGjXp6A8njVA+Nju19g7g==
@@ -0,0 +1 @@
encryptionsalt: v1:hy1rhuyush0=:v1:CIJbh/gDFQ0YxPP0:0+Vt7PEFzLcIS+OgxI1eH6qyTu4HMA==
@@ -0,0 +1 @@
encryptionsalt: v1:S591FZsC2xY=:v1:EL5tsMvtoQLo3dKy:B/SdC/zmTRMiVqVq/iq/qYKjREEtwg==
@@ -0,0 +1 @@
encryptionsalt: v1:hW4ggRSXmtY=:v1:AHzkGYnVoCq5dm7x:ixBIw+836wzBfjUgo2sZevU2x2FFcw==
@@ -0,0 +1,3 @@
name: vsphere-vm-lifecycle
runtime: python
description: pulumi-vsphere VirtualMachine create/destroy against the lab simulator
@@ -0,0 +1,71 @@
"""Create a lab VM with pulumi-vsphere VirtualMachine."""
from __future__ import annotations
import os
import uuid
import pulumi
import pulumi_vsphere as vsphere
user = os.environ.get("VSPHERE_USER", "administrator@vsphere.local")
password = os.environ.get("VSPHERE_PASSWORD", "VMware1!")
server = os.environ.get("VSPHERE_SERVER", "api-gateway")
datacenter_name = os.environ.get("VSPHERE_DATACENTER", "Datacenter")
datastore_name = os.environ.get("VSPHERE_DATASTORE", "datastore1")
cluster_name = os.environ.get("VSPHERE_CLUSTER", "Cluster")
network_name = os.environ.get("VSPHERE_NETWORK", "VM Network")
run_id = os.environ.get("TEST_RUN_ID") or uuid.uuid4().hex[:8]
vm_name = os.environ.get("VSPHERE_LAB_VM_NAME", f"pulumi-lab-{run_id}")
provider = vsphere.Provider(
"vsphere",
user=user,
password=password,
vsphere_server=server,
allow_unverified_ssl=True,
)
invoke_opts = pulumi.InvokeOptions(provider=provider)
res_opts = pulumi.ResourceOptions(provider=provider)
dc = vsphere.get_datacenter_output(name=datacenter_name, opts=invoke_opts)
ds = vsphere.get_datastore_output(
name=datastore_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
cluster = vsphere.get_compute_cluster_output(
name=cluster_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
network = vsphere.get_network_output(
name=network_name,
datacenter_id=dc.id,
opts=invoke_opts,
)
vm = vsphere.VirtualMachine(
"lab-vm",
name=vm_name,
resource_pool_id=cluster.resource_pool_id,
datastore_id=ds.id,
num_cpus=1,
memory=1024,
guest_id="otherGuest64",
wait_for_guest_net_timeout=0,
wait_for_guest_ip_timeout=0,
network_interfaces=[
vsphere.VirtualMachineNetworkInterfaceArgs(network_id=network.id),
],
disks=[
vsphere.VirtualMachineDiskArgs(label="disk0", size=16),
],
opts=res_opts,
)
pulumi.export("lab_vm_id", vm.id)
pulumi.export("lab_vm_name", vm.name)
pulumi.export("resource_pool_id", cluster.resource_pool_id)
pulumi.export("datastore_id", ds.id)
pulumi.export("network_id", network.id)
@@ -0,0 +1,2 @@
pulumi>=3.120,<4
pulumi-vsphere==4.17.0
+213
View File
@@ -0,0 +1,213 @@
"""HTML report for pulumi hybrid lab suite (pulumi-vsphere + REST + SOAP)."""
from __future__ import annotations
import html
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
def _esc(value: Any) -> str:
return html.escape(str(value), quote=True)
def _section_rest(summary: dict[str, Any]) -> str:
rest = summary.get("rest") or {}
majors = rest.get("majors") or []
by_verb = rest.get("by_verb") or {}
failures = rest.get("failures") or []
major_rows = []
for m in majors:
buckets = m.get("buckets") or {}
deep = m.get("deep_vs_stub") or {}
major_rows.append(
"<tr>"
f"<td>{_esc(m.get('major'))}</td>"
f"<td>{_esc(m.get('version'))}</td>"
f"<td>{_esc(m.get('probed'))}</td>"
f"<td>{_esc(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>"
f"<td>{_esc(deep.get('deep', 0))}/{_esc(deep.get('stub', 0))}</td>"
"</tr>"
)
verb_bits = " · ".join(f"{_esc(k)}={_esc(v)}" for k, v in sorted(by_verb.items()))
fail_rows = []
for f in failures[:40]:
fail_rows.append(
"<tr>"
f"<td>{_esc(f.get('major'))}</td>"
f"<td>{_esc(f.get('verb'))}</td>"
f"<td><code>{_esc(f.get('path'))}</code></td>"
f"<td>{_esc(f.get('kind'))}</td>"
f"<td>{_esc(f.get('status'))}</td>"
f"<td class='err'>{_esc((f.get('body') or f.get('expected') or '')[:180])}</td>"
"</tr>"
)
return f"""
<h2>REST matrix</h2>
<p class="meta">total={_esc(rest.get("total"))} · failed={_esc(rest.get("failed"))}
· verbs: {verb_bits or ""}</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>
</table>
<h3>REST 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 []
rows = []
for f in flows:
rows.append(
f"<tr class='{_esc(f.get('status'))}'>"
f"<td>{_esc(f.get('flow'))}</td>"
f"<td>{_esc(f.get('step'))}</td>"
f"<td>{_esc(f.get('status'))}</td>"
f"<td class='err'>{_esc(f.get('error') or '')}</td>"
f"</tr>"
)
return f"""
<h2>Deep REST CRUD</h2>
<p class="meta">total={_esc(crud.get("total"))} · failed={_esc(crud.get("failed"))}</p>
<table>
<thead><tr><th>Flow</th><th>Step</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips CRUD)</td></tr>'}</tbody>
</table>
"""
def _section_soap(summary: dict[str, Any]) -> str:
soap = summary.get("soap") or {}
ops = soap.get("ops") or []
rows = []
for op in ops:
status = "passed" if op.get("ok") else "failed"
rows.append(
f"<tr class='{_esc(status)}'>"
f"<td><code>{_esc(op.get('op'))}</code></td>"
f"<td>{_esc(op.get('status'))}</td>"
f"<td>{_esc(status)}</td>"
f"<td class='err'>{_esc(op.get('error') or '')}</td>"
f"</tr>"
)
return f"""
<h2>SOAP WSDL ops</h2>
<p class="meta">total={_esc(soap.get("total"))} · failed={_esc(soap.get("failed"))}</p>
<table>
<thead><tr><th>Operation</th><th>HTTP</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips SOAP)</td></tr>'}</tbody>
</table>
"""
def render_html(summary: dict[str, Any]) -> str:
generated = summary.get("generated_at") or datetime.now(UTC).isoformat()
cases = summary.get("cases") or []
total = len(cases)
passed = sum(1 for c in cases if c.get("status") == "passed")
failed = sum(1 for c in cases if c.get("status") == "failed")
skipped = sum(1 for c in cases if c.get("status") == "skipped")
rest = summary.get("rest") or {}
crud = summary.get("crud") or {}
soap = summary.get("soap") or {}
rows = []
for case in cases:
status = case.get("status") or ""
outs = case.get("outputs") or {}
out_html = "<br/>".join(f"<code>{_esc(k)}</code>={_esc(v)}" for k, v in outs.items())
err = _esc(case.get("error") or "")
rows.append(
f"<tr class='{_esc(status)}'>"
f"<td>{_esc(case.get('id'))}</td>"
f"<td>{_esc(case.get('title'))}</td>"
f"<td>{_esc(status)}</td>"
f"<td class='outs'>{out_html or ''}</td>"
f"<td class='err'>{err or ''}</td>"
f"</tr>"
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>pulumi hybrid lab report</title>
<style>
:root {{
--bg:#0f1419; --panel:#1a222c; --text:#e7eef7; --muted:#9aa8b8;
--ok:#3dd68c; --fail:#ff6b6b; --skip:#f0c14a; --line:#2a3542; --accent:#5b9fd4;
}}
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",sans-serif; background:var(--bg); color:var(--text); }}
header, main {{ max-width:1200px; margin:0 auto; padding:1.5rem; }}
h1 {{ margin:0 0 .4rem; }}
h2 {{ margin:1.75rem 0 .6rem; color:var(--accent); }}
h3 {{ margin:1rem 0 .4rem; font-size:1rem; color:var(--muted); }}
.meta {{ color:var(--muted); }}
.summary {{ display:grid; grid-template-columns:repeat(4,1fr); gap:.75rem; margin:1rem 0 1.5rem; }}
.stat {{ background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1rem; }}
.stat strong {{ display:block; font-size:1.5rem; margin-top:.25rem; }}
.ok strong {{ color:var(--ok); }} .fail strong {{ color:var(--fail); }} .skip strong {{ color:var(--skip); }}
table {{ width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line);
border-radius:12px; overflow:hidden; margin-bottom:1rem; }}
th, td {{ padding:.55rem .65rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:.9rem; }}
th {{ color:var(--accent); }}
tr.failed td:nth-child(3) {{ color:var(--fail); font-weight:600; }}
tr.passed td:nth-child(3) {{ color:var(--ok); }}
tr.skipped td:nth-child(3) {{ color:var(--skip); }}
code {{ font-family:ui-monospace,Menlo,monospace; font-size:.8rem; }}
.outs, .err {{ word-break:break-word; }}
.metrics {{ display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; margin-bottom:1rem; }}
</style>
</head>
<body>
<header>
<h1>pulumi hybrid lab suite</h1>
<div class="meta">Generated {_esc(generated)} · server {_esc(summary.get("vsphere_server"))}
· base {_esc(summary.get("vsphere_base"))}
· smoke={_esc(summary.get("smoke"))}</div>
</header>
<main>
<section class="summary">
<div class="stat"><span>Cases</span><strong>{total}</strong></div>
<div class="stat ok"><span>Passed</span><strong>{passed}</strong></div>
<div class="stat fail"><span>Failed</span><strong>{failed}</strong></div>
<div class="stat skip"><span>Skipped</span><strong>{skipped}</strong></div>
</section>
<section class="metrics">
<div class="stat"><span>REST failed</span><strong>{_esc(rest.get("failed", 0))}</strong>
<span class="meta"> / {_esc(rest.get("total", 0))} probes</span></div>
<div class="stat"><span>CRUD failed</span><strong>{_esc(crud.get("failed", 0))}</strong>
<span class="meta"> / {_esc(crud.get("total", 0))} flows</span></div>
<div class="stat"><span>SOAP failed</span><strong>{_esc(soap.get("failed", 0))}</strong>
<span class="meta"> / {_esc(soap.get("total", 0))} ops</span></div>
</section>
<h2>Suite cases</h2>
<table>
<thead><tr><th>ID</th><th>Title</th><th>Status</th><th>Outputs</th><th>Error</th></tr></thead>
<tbody>{"".join(rows)}</tbody>
</table>
{_section_rest(summary)}
{_section_crud(summary)}
{_section_soap(summary)}
</main>
</body>
</html>
"""
def write_report(summary: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(render_html(summary), encoding="utf-8")
View File
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
{
"generated_at": "2026-07-17T07:05:07.182576+00:00",
"vsphere_server": "api-gateway",
"smoke": false,
"provider": "pulumi-vsphere",
"cases": [
{
"id": "PU-INV",
"title": "Inventory data sources (datacenter/cluster/datastore/network/vm)",
"status": "passed",
"error": "",
"outputs": {
"cluster_id": "domain-c21",
"cluster_name": "Cluster",
"datacenter_id": "datacenter-21",
"datacenter_name": "Datacenter",
"datastore_id": "datastore-31",
"datastore_name": "datastore1",
"network_id": "network-41",
"network_name": "VM Network",
"resource_pool_id": "resgroup-22",
"vm_id": "4200aaaa-bbbb-cccc-dddd-000000000101",
"vm_name": "web-01"
}
},
{
"id": "PU-VM",
"title": "VirtualMachine create/destroy",
"status": "failed",
"error": " (most recent call last):\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 244, in <module>\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) executor.shutdown(wait=True)\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) ~~~~~~~~~~~~~~~~~^^^^^^^^^^^\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) File \"/usr/local/lib/python3.13/concurrent/futures/thread.py\", line 239, in shutdown\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) t.join()\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) ~~~~~~^^\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) File \"/usr/local/lib/python3.13/threading.py\", line 1095, in join\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) self._handle.join(timeout)\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) ~~~~~~~~~~~~~~~~~^^^^^^^^^\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab creating (0s) KeyboardInterrupt\n@ updating........\n + pulumi:pulumi:Stack vsphere-vm-lifecycle-pu-vm-lab **creating failed** 1 error; 27 messages\nDiagnostics:\n pulumi:pulumi:Stack (vsphere-vm-lifecycle-pu-vm-lab):\n error: update failed\n\n Traceback (most recent call last):\n File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 232, in <module>\n loop.run_until_complete(coro)\n ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 712, in run_until_complete\n self.run_forever()\n ~~~~~~~~~~~~~~~~^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 683, in run_forever\n self._run_once()\n ~~~~~~~~~~~~~~^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 2022, in _run_once\n event_list = self._selector.select(timeout)\n File \"/usr/local/lib/python3.13/selectors.py\", line 452, in select\n fd_event_list = self._selector.poll(timeout, max_ev)\n KeyboardInterrupt\n During handling of the above exception, another exception occurred:\n Traceback (most recent call last):\n File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 244, in <module>\n executor.shutdown(wait=True)\n ~~~~~~~~~~~~~~~~~^^^^^^^^^^^\n File \"/usr/local/lib/python3.13/concurrent/futures/thread.py\", line 239, in shutdown\n t.join()\n ~~~~~~^^\n File \"/usr/local/lib/python3.13/threading.py\", line 1095, in join\n self._handle.join(timeout)\n ~~~~~~~~~~~~~~~~~^^^^^^^^^\n KeyboardInterrupt\n\n vsphere:index:VirtualMachine (lab-vm):\n error: sdk-v2/provider2.go:572: sdk.helper_schema: error creating virtual machine: ManagedObjectNotFound: provider=vsphere@4.17.0\n error: 1 error occurred:\n \t* error creating virtual machine: ManagedObjectNotFound\n\nResources:\n + 2 created\n 2 errored\n\nDuration: 1s\n\n stderr: ",
"outputs": {}
},
{
"id": "PU-TAG",
"title": "TagCategory + Tag create/destroy",
"status": "failed",
"error": "reating (0s) During handling of the above exception, another exception occurred:\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) Traceback (most recent call last):\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 244, in <module>\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) executor.shutdown(wait=True)\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) ~~~~~~~~~~~~~~~~~^^^^^^^^^^^\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) File \"/usr/local/lib/python3.13/concurrent/futures/thread.py\", line 239, in shutdown\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) t.join()\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) ~~~~~~^^\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) File \"/usr/local/lib/python3.13/threading.py\", line 1095, in join\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) self._handle.join(timeout)\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) ~~~~~~~~~~~~~~~~~^^^^^^^^^\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab creating (0s) KeyboardInterrupt\n@ updating........\n + pulumi:pulumi:Stack vsphere-tags-pu-tag-lab **creating failed** 1 error; 27 messages\nDiagnostics:\n pulumi:pulumi:Stack (vsphere-tags-pu-tag-lab):\n error: update failed\n\n Traceback (most recent call last):\n File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 232, in <module>\n loop.run_until_complete(coro)\n ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 712, in run_until_complete\n self.run_forever()\n ~~~~~~~~~~~~~~~~^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 683, in run_forever\n self._run_once()\n ~~~~~~~~~~~~~~^^\n File \"/usr/local/lib/python3.13/asyncio/base_events.py\", line 2022, in _run_once\n event_list = self._selector.select(timeout)\n File \"/usr/local/lib/python3.13/selectors.py\", line 452, in select\n fd_event_list = self._selector.poll(timeout, max_ev)\n KeyboardInterrupt\n During handling of the above exception, another exception occurred:\n Traceback (most recent call last):\n File \"/root/.pulumi/bin/pulumi-language-python-exec\", line 244, in <module>\n executor.shutdown(wait=True)\n ~~~~~~~~~~~~~~~~~^^^^^^^^^^^\n File \"/usr/local/lib/python3.13/concurrent/futures/thread.py\", line 239, in shutdown\n t.join()\n ~~~~~~^^\n File \"/usr/local/lib/python3.13/threading.py\", line 1095, in join\n self._handle.join(timeout)\n ~~~~~~~~~~~~~~~~~^^^^^^^^^\n KeyboardInterrupt\n\n vsphere:index:TagCategory (lab-category):\n error: expected non-nil error with nil state during Create of urn:pulumi:pu-tag-lab::vsphere-tags::vsphere:index/tagCategory:TagCategory::lab-category\n\nResources:\n + 2 created\n 2 errored\n\nDuration: 1s\n\n stderr: ",
"outputs": {}
}
],
"total_failed": 2
}
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""Hybrid pulumi-tests suite: pulumi-vsphere + full REST matrix + CRUD + SOAP WSDL ops."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import traceback
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "lib"))
from assert_nonempty import assert_nonempty # noqa: E402
from report_html import write_report # noqa: E402
SMOKE = os.environ.get("TEST_SMOKE", "").lower() in {"1", "true", "yes"}
REPORT_DIR = Path(os.environ.get("REPORT_DIR", "/reports"))
JUNIT_PATH = Path(os.environ.get("REPORT_PATH", str(REPORT_DIR / "pulumi-junit.xml")))
HTML_PATH = Path(os.environ.get("HTML_REPORT_PATH", str(REPORT_DIR / "pulumi-report.html")))
JSON_PATH = Path(os.environ.get("JSON_REPORT_PATH", str(REPORT_DIR / "pulumi-summary.json")))
PROGRAMS = ROOT / "programs"
# Ensure VSPHERE_BASE for HTTP probes when only VSPHERE_SERVER is set (compose).
if not os.environ.get("VSPHERE_BASE") and os.environ.get("VSPHERE_SERVER"):
server = os.environ["VSPHERE_SERVER"]
if not server.startswith("http://") and not server.startswith("https://"):
os.environ["VSPHERE_BASE"] = f"https://{server}"
PULUMI_CASES = [
{
"id": "PU-INV",
"title": "Inventory data sources (dc/cluster/ds/net/vm/host/folder/pool)",
"dir": "inventory",
"smoke": True,
"required": [
"datacenter_id",
"datacenter_name",
"datastore_id",
"datastore_name",
"cluster_id",
"cluster_name",
"resource_pool_id",
"resource_pool_name",
"cluster_resource_pool_id",
"network_id",
"network_name",
"vm_id",
"vm_name",
"host_id",
"host_name",
"folder_id",
"folder_path",
],
},
{
"id": "PU-FOLDER",
"title": "Folder create/destroy",
"dir": "folders",
"smoke": False,
"required": ["folder_id", "folder_path", "datacenter_id"],
},
{
"id": "PU-VM",
"title": "VirtualMachine create/destroy",
"dir": "vm_lifecycle",
"smoke": False,
"required": [
"lab_vm_id",
"lab_vm_name",
"resource_pool_id",
"datastore_id",
"network_id",
],
},
{
"id": "PU-TAG",
"title": "TagCategory + Tag create/destroy",
"dir": "tags",
"smoke": False,
"required": ["category_id", "category_name", "tag_id", "tag_name"],
},
]
def _ensure_program_deps() -> None:
"""Install per-program requirements once (image usually already has them)."""
seen: set[str] = set()
for case in PULUMI_CASES:
req = PROGRAMS / case["dir"] / "requirements.txt"
key = str(req.resolve()) if req.exists() else ""
if not key or key in seen:
continue
seen.add(key)
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "-r", str(req)],
check=False,
capture_output=True,
)
def _run_pulumi_case(case: dict) -> dict:
work_dir = PROGRAMS / case["dir"]
if not (work_dir / "__main__.py").is_file():
return {
"id": case["id"],
"title": case["title"],
"status": "failed",
"error": f"missing program {work_dir}",
"outputs": {},
}
try:
from pulumi import automation as auto
except Exception as exc: # noqa: BLE001
return {
"id": case["id"],
"title": case["title"],
"status": "failed",
"error": f"pulumi automation unavailable: {exc}",
"outputs": {},
}
os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "lab")
os.environ.setdefault("PULUMI_BACKEND_URL", "file:///tmp/pulumi-state")
Path("/tmp/pulumi-state").mkdir(parents=True, exist_ok=True)
stack_name = f"{case['id'].lower()}-{os.environ.get('TEST_RUN_ID', 'lab')[:8]}"
try:
stack = auto.create_or_select_stack(stack_name=stack_name, work_dir=str(work_dir))
try:
result = stack.up(on_output=lambda *_: None)
outputs = {k: v.value for k, v in (result.outputs or {}).items()}
errors = assert_nonempty(outputs, required=case["required"])
if errors:
return {
"id": case["id"],
"title": case["title"],
"status": "failed",
"error": "; ".join(errors),
"outputs": outputs,
}
return {
"id": case["id"],
"title": case["title"],
"status": "passed",
"error": "",
"outputs": outputs,
}
finally:
try:
stack.destroy(on_output=lambda *_: None)
except Exception:
pass
try:
stack.workspace.remove_stack(stack_name)
except Exception:
pass
except Exception as exc: # noqa: BLE001
return {
"id": case["id"],
"title": case["title"],
"status": "failed",
"error": str(exc)[-3000:],
"outputs": {},
}
def _run_rest_matrix() -> dict:
from rest_matrix import run_rest_matrix
majors = [9] if SMOKE else [6, 7, 8, 9]
try:
summary = run_rest_matrix(majors=majors)
except Exception as exc: # noqa: BLE001
return {
"id": "PU-REST",
"title": f"REST full matrix majors={majors}",
"status": "failed",
"error": f"{exc}\n{traceback.format_exc()[-1500:]}",
"outputs": {},
"rest": {"total": 0, "failed": 1, "failures": [{"error": str(exc)}]},
}
status = "passed" if summary.get("ok") else "failed"
err = ""
if not summary.get("ok"):
sample = summary.get("failures") or []
err = f"REST matrix failures={summary.get('failed')} total={summary.get('total')}; "
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)",
"status": status,
"error": err,
"outputs": {
"total": summary.get("total"),
"failed": summary.get("failed"),
"by_verb": summary.get("by_verb"),
"majors": [m.get("major") for m in summary.get("majors") or []],
},
"rest": summary,
}
def _run_rest_crud() -> dict:
from rest_crud import run_rest_crud
try:
summary = run_rest_crud()
except Exception as exc: # noqa: BLE001
return {
"id": "PU-CRUD",
"title": "Deep REST CRUD (session/folder/tag/library/vm)",
"status": "failed",
"error": f"{exc}\n{traceback.format_exc()[-1500:]}",
"outputs": {},
"crud": {"total": 0, "failed": 1, "failures": [{"error": str(exc)}]},
}
status = "passed" if summary.get("ok") else "failed"
err = ""
if not summary.get("ok"):
err = "; ".join(
f"{f.get('flow')}/{f.get('step')}: {f.get('error')}"
for f in (summary.get("failures") or [])
)
return {
"id": "PU-CRUD",
"title": "Deep REST CRUD (session/folder/tag/library/vm)",
"status": status,
"error": err,
"outputs": {
"total": summary.get("total"),
"failed": summary.get("failed"),
"flows": [f.get("flow") for f in summary.get("flows") or []],
},
"crud": summary,
}
def _run_soap_ops() -> dict:
from soap_ops import run_soap_ops
try:
summary = run_soap_ops()
except Exception as exc: # noqa: BLE001
return {
"id": "PU-SOAP",
"title": "SOAP WSDL ops (all /sdk operations)",
"status": "failed",
"error": f"{exc}\n{traceback.format_exc()[-1500:]}",
"outputs": {},
"soap": {"total": 0, "failed": 1, "failures": [{"error": str(exc)}]},
}
status = "passed" if summary.get("ok") else "failed"
err = ""
if not summary.get("ok"):
err = "; ".join(
f"{f.get('op')}: {f.get('error')}" for f in (summary.get("failures") or [])[:12]
)
return {
"id": "PU-SOAP",
"title": f"SOAP WSDL ops ({summary.get('wsdl_ops', '?')} operations)",
"status": status,
"error": err,
"outputs": {
"total": summary.get("total"),
"failed": summary.get("failed"),
"wsdl_ops": summary.get("wsdl_ops"),
},
"soap": summary,
}
def main() -> int:
REPORT_DIR.mkdir(parents=True, exist_ok=True)
_ensure_program_deps()
pulumi_cases = [c for c in PULUMI_CASES if c["smoke"]] if SMOKE else PULUMI_CASES
print(
f"pulumi hybrid suite pulumi_cases={len(pulumi_cases)} smoke={SMOKE} "
f"server={os.environ.get('VSPHERE_SERVER', 'api-gateway')} "
f"base={os.environ.get('VSPHERE_BASE', '')}",
flush=True,
)
results: list[dict] = []
failed = 0
for case in pulumi_cases:
print(f"== {case['id']}: {case['title']} ==", flush=True)
result = _run_pulumi_case(case)
results.append(result)
print(f"{case['id']}: {result['status']}", flush=True)
if result["status"] == "failed":
failed += 1
print(result["error"][:800], flush=True)
# REST matrix (smoke = major 9 only; full = 69)
print("== PU-REST: REST matrix ==", flush=True)
rest_result = _run_rest_matrix()
results.append(rest_result)
print(f"PU-REST: {rest_result['status']}", flush=True)
if rest_result["status"] == "failed":
failed += 1
print(rest_result["error"][:800], flush=True)
rest_summary = rest_result.get("rest") or {}
crud_summary: dict = {}
soap_summary: dict = {}
if not SMOKE:
print("== PU-CRUD: deep REST CRUD ==", flush=True)
crud_result = _run_rest_crud()
results.append(crud_result)
crud_summary = crud_result.get("crud") or {}
print(f"PU-CRUD: {crud_result['status']}", flush=True)
if crud_result["status"] == "failed":
failed += 1
print(crud_result["error"][:800], flush=True)
print("== PU-SOAP: WSDL ops ==", flush=True)
soap_result = _run_soap_ops()
results.append(soap_result)
soap_summary = soap_result.get("soap") or {}
print(f"PU-SOAP: {soap_result['status']}", flush=True)
if soap_result["status"] == "failed":
failed += 1
print(soap_result["error"][:800], flush=True)
summary = {
"generated_at": datetime.now(UTC).isoformat(),
"vsphere_server": os.environ.get("VSPHERE_SERVER", "api-gateway"),
"vsphere_base": os.environ.get("VSPHERE_BASE", ""),
"smoke": SMOKE,
"provider": "pulumi-vsphere+rest+soap",
"cases": results,
"total_failed": failed,
"rest": {
"total": rest_summary.get("total", 0),
"failed": rest_summary.get("failed", 0),
"by_verb": rest_summary.get("by_verb"),
"majors": rest_summary.get("majors"),
"failures": rest_summary.get("failures"),
},
"crud": {
"total": crud_summary.get("total", 0),
"failed": crud_summary.get("failed", 0),
"flows": crud_summary.get("flows"),
"failures": crud_summary.get("failures"),
},
"soap": {
"total": soap_summary.get("total", 0),
"failed": soap_summary.get("failed", 0),
"ops": soap_summary.get("ops"),
"failures": soap_summary.get("failures"),
},
}
JSON_PATH.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
write_report(summary, HTML_PATH)
suite = ET.Element(
"testsuite",
name="pulumi-hybrid",
tests=str(len(results)),
failures=str(failed),
)
for result in results:
node = ET.SubElement(
suite,
"testcase",
classname="pulumi-hybrid",
name=f"{result['id']} {result['title']}",
)
if result["status"] == "failed":
ET.SubElement(node, "failure", message=result["error"][:500])
elif result["status"] == "skipped":
ET.SubElement(node, "skipped", message=result.get("error") or "skipped")
ET.ElementTree(suite).write(JUNIT_PATH, encoding="utf-8", xml_declaration=True)
print(f"Wrote {HTML_PATH}", flush=True)
print(f"Wrote {JSON_PATH}", flush=True)
print(f"Wrote {JUNIT_PATH}", flush=True)
print(
f"SUMMARY failed={failed} total={len(results)} "
f"rest.failed={summary['rest']['failed']} "
f"crud.failed={summary['crud']['failed']} "
f"soap.failed={summary['soap']['failed']}",
flush=True,
)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())