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:
@@ -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/"
|
||||
@@ -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 **6–9** 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 6–9 (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×6–9 + 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.
|
||||
@@ -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 **6–9** (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 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`.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Из **корня репозитория**:
|
||||
|
||||
```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×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.*`.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
name: vsphere-tags
|
||||
runtime: python
|
||||
description: pulumi-vsphere TagCategory + Tag against the lab simulator
|
||||
@@ -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
|
||||
@@ -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")
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
@@ -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 = 6–9)
|
||||
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())
|
||||
Reference in New Issue
Block a user