Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL-backed integration tests."""
+130
View File
@@ -0,0 +1,130 @@
"""PostgreSQL migration acceptance checks."""
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.db.repositories.resources import ResourceRepository
from app.simulation.seed import apply_seed, small_profile
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
async def test_migration_is_repeatable_and_constraints_hold() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
assert await migrate(connection) == 0
node_id = uuid.uuid4()
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'online') ON CONFLICT DO NOTHING",
node_id,
f"test-{node_id}",
)
with pytest.raises(asyncpg.CheckViolationError):
async with connection.transaction():
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'invalid')",
uuid.uuid4(),
f"invalid-{node_id}",
)
finally:
await connection.close()
async def test_small_seed_is_idempotent() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await apply_seed(connection, small_profile())
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
assert (
await connection.fetchval(
"""SELECT count(*) FROM resources
WHERE external_id IN ('100', '101', '200', 'local', 'local-lvm')"""
)
== 5
)
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
assert await connection.fetchval("SELECT count(*) FROM containers") == 1
assert await connection.fetchval("SELECT count(*) FROM storages") == 2
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1
assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1
assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1
assert await connection.fetchval("SELECT count(*) FROM roles") == 3
assert await connection.fetchval("SELECT count(*) FROM api_tokens") == 4
secrets = await connection.fetch("SELECT secret_hash FROM api_tokens")
assert all(str(row["secret_hash"]).startswith("scrypt$") for row in secrets)
assert all("-secret" not in str(row["secret_hash"]) for row in secrets)
finally:
await connection.close()
async def test_demo_cluster_seed_populates_realistic_state() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
from app.simulation.seed import build_profile
await apply_seed(connection, build_profile("demo-cluster"))
assert await connection.fetchval("SELECT count(*) FROM nodes") == 20
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 850
assert await connection.fetchval("SELECT count(*) FROM containers") == 150
assert (
await connection.fetchval("SELECT count(*) FROM resources WHERE kind = 'ceph-osd'")
== 300
)
assert await connection.fetchval("SELECT count(*) FROM backups") >= 400
assert await connection.fetchval("SELECT count(*) FROM task_logs") >= 500
assert await connection.fetchval("SELECT count(*) FROM snapshots") >= 100
ceph_capacity = await connection.fetchval(
"SELECT capacity_bytes FROM storages WHERE storage_id = 'ceph-prod'"
)
assert ceph_capacity == 5 * 1024**5
profile = await connection.fetchval("SELECT metadata->>'profile' FROM clusters LIMIT 1")
assert profile == "demo-cluster"
finally:
await connection.close()
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
url = os.environ["TEST_DATABASE_URL"]
connection = await asyncpg.connect(url)
database = AsyncpgDatabase(Settings(database_url=url))
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await database.connect()
assert await database.is_ready()
repository = ResourceRepository(database.pool)
resource = await repository.get(kind="qemu", external_id="101")
assert resource is not None
updated = await repository.update_state(
resource.id,
expected_version=resource.version,
state={**resource.state, "status": "running"},
)
assert updated.version == resource.version + 1
assert updated.state["status"] == "running"
with pytest.raises(ConflictError):
await repository.update_state(
resource.id,
expected_version=resource.version,
state=resource.state,
)
finally:
await database.close()
await connection.close()
+81
View File
@@ -0,0 +1,81 @@
"""Durable task concurrency and recovery tests."""
import asyncio
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from asyncpg import Pool
from app.db.migrations import migrate
from app.tasks.repository import TaskRepository
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
async def repository() -> tuple[Pool, TaskRepository]:
pool = await asyncpg.create_pool(os.environ["TEST_DATABASE_URL"], min_size=1, max_size=4)
async with pool.acquire() as connection:
await migrate(connection)
return pool, TaskRepository(pool)
async def test_two_worker_exclusion_idempotency_and_logs() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={"value": 1},
resource_key=f"vm:{key}",
idempotency_key=key,
)
repeated = await tasks.create(
upid=f"ignored-{key}", task_type="test", payload={}, idempotency_key=key
)
assert repeated.id == created.id
first, second = await asyncio.gather(
tasks.claim("worker-a", 30), tasks.claim("worker-b", 30)
)
claimed = first or second
assert claimed is not None
assert (first is None) != (second is None)
worker = "worker-a" if first is not None else "worker-b"
await tasks.append_log(claimed.id, "started")
await tasks.progress(claimed.id, worker, 50)
await tasks.finish(claimed.id, worker, status="success", result={"ok": True})
assert await tasks.logs(claimed.id) == ("started",)
finished = await tasks.get(claimed.id)
assert finished is not None
assert finished.status == "success"
finally:
await pool.close()
async def test_expired_lease_is_reclaimed_after_restart() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={},
)
assert await tasks.claim("dead-worker", 0) is not None
recovered = await tasks.claim("new-worker", 30)
assert recovered is not None
assert recovered.id == created.id
assert recovered.attempt == 2
await tasks.request_cancel(recovered.id)
cancelled = await tasks.get(recovered.id)
assert cancelled is not None
assert cancelled.cancel_requested
await tasks.finish(recovered.id, "new-worker", status="cancelled")
finally:
await pool.close()
+168
View File
@@ -0,0 +1,168 @@
"""Integration smoke for native vSphere REST + SOAP."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(client: AsyncClient) -> dict[str, str]:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert login.status_code == 201
return {"vmware-api-session-id": login.json()}
async def test_rest_session_inventory_power_clone_tags(client: AsyncClient) -> None:
headers = await _session(client)
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
assert len(vms.json()) >= 5
vm_id = next(item["vm"] for item in vms.json() if item["name"] == "app-01")
power = await client.post(
f"/api/vcenter/vm/{vm_id}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 200
assert str(power.json().get("task") or "").startswith("task-")
snap = await client.post(
f"/api/vcenter/vm/{vm_id}/snapshots",
headers=headers,
json={"name": "pre-update", "description": "lab"},
)
assert snap.status_code == 200
assert "snapshot" in snap.json()
clone = await client.post(
f"/api/vcenter/vm/{vm_id}/clone",
headers=headers,
json={"name": "app-01-clone"},
)
assert clone.status_code == 200
assert clone.json()["vm"].startswith("vm-")
cat = await client.post(
"/api/cis/tagging/category",
headers=headers,
json={"name": "Owner-api-test", "associable_types": ["VirtualMachine"]},
)
assert cat.status_code == 200, cat.text
tag = await client.post(
"/api/cis/tagging/tag",
headers=headers,
json={"category_id": cat.json(), "name": "team-a"},
)
assert tag.status_code == 200
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert len(libs.json()) >= 1
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert versions.json()["plane"] == "vsphere-rest"
async def test_readonly_cannot_mutate(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("readonly@vsphere.local", "VMware1!"),
)
assert login.status_code == 201
headers = {"vmware-api-session-id": login.json()}
session = await client.get("/api/session", headers=headers)
assert session.status_code == 200
assert session.content in (b"", b"null") or not session.text.strip()
assert "ReadOnly" in (session.headers.get("x-vmware-session-roles") or "")
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
assert len(vms.json()) >= 5
power = await client.post(
f"/api/vcenter/vm/{vms.json()[0]['vm']}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 403
async def test_soap_property_collector_and_wsdl(client: AsyncClient) -> None:
content = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body><RetrieveServiceContent xmlns="urn:vim25"><_this type="ServiceInstance">ServiceInstance</_this></RetrieveServiceContent></soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert content.status_code == 200
assert "SessionManager" in content.text
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
cookie = login.headers.get("set-cookie") or ""
assert "vmware_soap_session" in cookie
updates = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version></version>
</WaitForUpdatesEx>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie.split(";")[0]},
)
assert updates.status_code == 200
assert "WaitForUpdatesExResponse" in updates.text
assert "vm-101" in updates.text
wsdl = await client.get("/sdk/vimService.wsdl")
assert wsdl.status_code == 200
assert "VimService" in wsdl.text
@@ -0,0 +1,86 @@
"""Integration: seeded Automation API surface returns real DB-backed data."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="demo-cluster")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(client: AsyncClient) -> dict[str, str]:
login = await client.post("/api/session", auth=("administrator@vsphere.local", "VMware1!"))
assert login.status_code == 201, login.text
return {"vmware-api-session-id": login.json()}
async def test_demo_cluster_api_state_and_inventory(client: AsyncClient) -> None:
headers = await _session(client)
vm_list = await client.get("/api/vcenter/vm", headers=headers)
assert vm_list.status_code == 200
assert len(vm_list.json()) >= 1000
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
assert len(hosts.json()) >= 20
ssh = await client.get("/api/appliance/access/ssh", headers=headers)
assert ssh.status_code == 200
assert ssh.json().get("enabled") is True
assert "stub" not in ssh.json()
supervisors = await client.get(
"/api/vcenter/namespace-management/supervisors/supervisor-1/summary",
headers=headers,
)
assert supervisors.status_code == 200
body = supervisors.json()
assert isinstance(body, dict)
assert "stub" not in body
assert body.get("status") == "ENABLED" or body.get("name") or body.get("id")
esx = await client.get("/api/esx/settings/clusters/domain-c21/software", headers=headers)
assert esx.status_code == 200
assert esx.json().get("status") == "COMPLIANT"
assert "domain-c21" in (esx.json().get("clusters") or [])
cdroms = await client.get("/api/vcenter/vm/vm-101/hardware/cdrom", headers=headers)
assert cdroms.status_code == 200
assert isinstance(cdroms.json(), list)
assert cdroms.json()[0]["cdrom"] == "3000"
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert len(libs.json()) >= 2
put = await client.put("/api/appliance/access/ssh", headers=headers, json={"enabled": False})
assert put.status_code in {200, 204}
if put.status_code == 200:
assert put.json().get("enabled") is False
ssh2 = await client.get("/api/appliance/access/ssh", headers=headers)
assert ssh2.json().get("enabled") is False
await client.put("/api/appliance/access/ssh", headers=headers, json={"enabled": True})
+450
View File
@@ -0,0 +1,450 @@
"""Full vSphere REST + SOAP + major-matrix surface tests."""
from __future__ import annotations
import os
import re
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import IMPLEMENTED, catalog_entries
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
_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",
"{session_id}": "session-lab-1",
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
"{permission_id}": "1",
"{policy}": "policy-default",
"{supervisor}": "supervisor-1",
"{namespace}": "ns-lab-1",
"{provider}": "vsphere.local",
"{interface}": "nic0",
"{network}": "network-41",
"{cdrom}": "3000",
"{disk}": "2000",
"{nic}": "4000",
"{adapter}": "1000",
"{service}": "vsphere-ui",
"{domain}": "lab.local",
}
def _concrete(path: str) -> str:
out = path
for key, value in _PATH_SUBS.items():
out = out.replace(key, value)
return out
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(
client: AsyncClient, user: str = "administrator@vsphere.local"
) -> dict[str, str]:
login = await client.post("/api/session", auth=(user, "VMware1!"))
assert login.status_code == 201, login.text
sid = login.json()
assert isinstance(sid, str) and sid
return {"vmware-api-session-id": sid}
@pytest.mark.parametrize("major", sorted(VERSIONS))
async def test_ui_catalog_and_method_fields_for_every_major(
client: AsyncClient, major: int
) -> None:
catalog = await client.get("/ui/api/catalog", params={"major": major})
assert catalog.status_code == 200
body = catalog.json()
assert body["plane"] == "vsphere-rest"
assert body["method_count"] == len(catalog_entries_for_major(major))
# Spot-check a path with params on majors that include VM get.
method = await client.get(
"/ui/api/method",
params={"major": major, "path": "/api/vcenter/vm/{vm}", "verb": "GET"},
)
assert method.status_code == 200
payload = method.json()
if payload.get("implemented"):
assert any(f["name"] == "vm" for f in payload["path_fields"])
async def test_all_coverage_routes_no_server_error(client: AsyncClient) -> None:
headers = await _session(client)
failures: list[str] = []
for entry in catalog_entries():
verb = entry["verb"]
path = entry["path"]
if verb == "DELETE" and path == "/api/session":
continue
url = _concrete(path)
kwargs: dict = {"headers": headers}
if verb in {"POST", "PATCH", "PUT"}:
kwargs["headers"] = {**headers, "Content-Type": "application/json"}
if path.endswith("/power"):
url = f"{url}?action=start"
kwargs["json"] = {}
elif "tag-association" in path:
kwargs["json"] = {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
}
else:
kwargs["json"] = {}
response = await client.request(verb, url, **kwargs)
if response.status_code >= 500:
failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}")
assert failures == [], "\n".join(failures)
async def test_rest_inventory_returns_seed_data(client: AsyncClient) -> None:
headers = await _session(client)
for path, min_count in (
("/api/vcenter/vm", 5),
("/api/vcenter/host", 3),
("/api/vcenter/datastore", 1),
("/api/vcenter/network", 1),
("/api/vcenter/datacenter", 1),
("/api/vcenter/cluster", 1),
("/api/vcenter/folder", 1),
):
response = await client.get(path, headers=headers)
assert response.status_code == 200, path
assert len(response.json()) >= min_count, path
async def test_legacy_rest_wrappers(client: AsyncClient) -> None:
headers = await _session(client)
for path in (
"/rest/vcenter/vm",
"/rest/vcenter/host",
"/rest/vcenter/datastore",
"/rest/vcenter/network",
"/rest/vcenter/datacenter",
"/rest/vcenter/cluster",
"/rest/appliance/system/version",
):
response = await client.get(path, headers=headers)
assert response.status_code == 200, path
body = response.json()
assert "value" in body
async def test_session_contracts(client: AsyncClient) -> None:
headers = await _session(client)
get_session = await client.get("/api/session", headers=headers)
assert get_session.status_code == 200
assert get_session.content in (b"", b"null") or not get_session.text.strip()
assert "Administrator" in (get_session.headers.get("x-vmware-session-roles") or "")
legacy = await client.post(
"/rest/com/vmware/cis/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert legacy.status_code in {200, 201}
assert legacy.json()["value"]
legacy_get = await client.get(
"/rest/com/vmware/cis/session",
headers={"vmware-api-session-id": legacy.json()["value"]},
)
assert legacy_get.status_code == 200
assert legacy_get.json()["value"]
async def test_authz_readonly_forbidden_on_power(client: AsyncClient) -> None:
headers = await _session(client, "readonly@vsphere.local")
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
vm = vms.json()[0]["vm"]
power = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 403
async def test_appliance_version_public_and_health(client: AsyncClient) -> None:
version = await client.get("/api/appliance/system/version")
assert version.status_code == 200
assert version.json()["version"]
headers = await _session(client)
health = await client.get("/api/appliance/health/system", headers=headers)
assert health.status_code == 200
assert health.json()["status"] == "green"
networking = await client.get("/api/appliance/networking", headers=headers)
assert networking.status_code == 200
assert networking.json()["hostname"]
async def test_soap_login_service_content_and_inventory(client: AsyncClient) -> None:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
assert "LoginResponse" in login.text
cookie = (login.headers.get("set-cookie") or "").split(";")[0]
assert "vmware_soap_session" in cookie
content = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveServiceContent xmlns="urn:vim25">
<_this type="ServiceInstance">ServiceInstance</_this>
</RetrieveServiceContent>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert content.status_code == 200
assert "propertyCollector" in content.text
assert "eventManager" in content.text
props = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveProperties xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<specSet>
<propSet><type>Folder</type><pathSet>childEntity</pathSet><pathSet>name</pathSet></propSet>
<objectSet>
<obj type="Folder">group-d1</obj>
<selectSet xsi:type="TraversalSpec">
<type>Folder</type><path>childEntity</path>
</selectSet>
</objectSet>
</specSet>
</RetrieveProperties>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert props.status_code == 200
assert "datacenter-21" in props.text or "Datacenter" in props.text
events = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<QueryEvents xmlns="urn:vim25">
<_this type="EventManager">EventManager</_this>
</QueryEvents>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert events.status_code == 200
assert "QueryEventsResponse" in events.text
async def test_vm_lifecycle_and_power(client: AsyncClient) -> None:
headers = await _session(client)
created = await client.post(
"/api/vcenter/vm",
headers={**headers, "Content-Type": "application/json"},
json={
"name": "full-api-lifecycle",
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
"cpu_count": 1,
"memory_size_MiB": 512,
},
)
assert created.status_code in {200, 201}, created.text
vm = created.json()
if isinstance(vm, dict):
vm = vm.get("vm") or vm.get("value") or vm
assert isinstance(vm, str)
detail = await client.get(f"/api/vcenter/vm/{vm}", headers=headers)
assert detail.status_code == 200
power = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code in {200, 204}, power.text
# powered-on VMs cannot be deleted — stop first (vSphere semantics)
stop = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "stop"},
headers=headers,
)
assert stop.status_code in {200, 204}, stop.text
deleted = await client.delete(f"/api/vcenter/vm/{vm}", headers=headers)
assert deleted.status_code in {200, 204}, deleted.text
async def test_coverage_registry_matches_implemented_constant() -> None:
assert len(catalog_entries()) == len(IMPLEMENTED)
for verb, path in IMPLEMENTED:
assert re.match(r"^/(api|rest)/", path), path
assert verb in {"GET", "POST", "PUT", "PATCH", "DELETE"}
@pytest.mark.parametrize("major", sorted(VERSIONS))
async def test_major_matrix_all_verbs_no_server_error(client: AsyncClient, major: int) -> None:
"""Apply each catalog major and exercise every registered GET/POST/PATCH/DELETE."""
headers = await _session(client)
apply = await client.post("/ui/api/contract/apply", params={"major": major})
assert apply.status_code == 200, apply.text
failures: list[str] = []
order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4}
entries = sorted(
catalog_entries_for_major(major),
key=lambda item: (order.get(item["verb"], 9), item["path"]),
)
for entry in entries:
verb = entry["verb"]
path = entry["path"]
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
url = _concrete(path)
kwargs: dict = {"headers": {**headers}}
if verb in {"POST", "PATCH", "PUT"}:
kwargs["headers"] = {**headers, "Content-Type": "application/json"}
if path.endswith("/power") and "/guest/" not in path:
url = f"{url}?action=start"
kwargs["json"] = {}
elif path.endswith("/guest/power"):
url = f"{url}?action=reboot"
kwargs["json"] = {}
elif path.endswith("/maintenance"):
url = f"{url}?action=enter"
kwargs["json"] = {}
elif path == "/api/vcenter/folder/{folder}":
url = f"{url}?action=rename"
kwargs["json"] = {"name": "renamed-by-matrix"}
elif path == "/api/content/local-library":
kwargs["json"] = {"create_spec": {"name": f"lib-m{major}-{os.urandom(3).hex()}"}}
elif path == "/api/cis/tagging/category":
kwargs["json"] = {
"create_spec": {
"name": f"cat-m{major}-{os.urandom(3).hex()}",
"cardinality": "MULTIPLE",
"associable_types": [],
}
}
elif path == "/api/cis/tagging/tag":
kwargs["json"] = {
"create_spec": {
"name": f"tag-m{major}-{os.urandom(3).hex()}",
"category_id": "missing-category",
}
}
elif "tag-association" in path:
kwargs["json"] = {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
}
elif path == "/api/vcenter/network/dvpg":
kwargs["json"] = {
"name": f"dvpg-m{major}-{os.urandom(2).hex()}",
"dvs": "dvs-51",
"vlan_id": 20,
}
elif path == "/api/content/library/item":
kwargs["json"] = {
"create_spec": {
"library_id": "lib-missing",
"name": f"item-m{major}-{os.urandom(2).hex()}",
"type": "ovf",
}
}
elif path == "/api/vcenter/authorization/permissions":
kwargs["json"] = {
"principal": "readonly@vsphere.local",
"role": "ReadOnly",
"entity": "datacenter-21",
}
elif path == "/api/vcenter/datastore/{datastore}/files":
kwargs["json"] = {
"path": f"/probe-m{major}-{os.urandom(2).hex()}.txt",
"size": 1,
"type": "FILE",
}
elif path.endswith("/hardware/cpu"):
kwargs["json"] = {"count": 2}
elif path.endswith("/hardware/memory"):
kwargs["json"] = {"size_MiB": 1024}
elif path.endswith("/hardware/disk"):
kwargs["json"] = {"type": "SCSI", "new_vmdk": {"capacity": 1024}}
elif path.endswith("/hardware/ethernet"):
kwargs["json"] = {
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
}
elif path.endswith("/snapshots") and verb == "POST":
kwargs["json"] = {"name": f"snap-m{major}-{os.urandom(2).hex()}"}
elif "/snapshots/" in path and verb == "POST":
kwargs["json"] = {"action": "revert"}
elif path.endswith("/clone"):
kwargs["json"] = {
"name": f"clone-m{major}-{os.urandom(2).hex()}",
"placement": {"folder": "group-v23", "host": "host-11"},
}
elif path.endswith("/relocate"):
kwargs["json"] = {"placement": {"host": "host-12"}}
elif path.endswith("/console/tickets"):
kwargs["json"] = {"type": "WEBMKS"}
elif path.endswith("/guest/customization"):
kwargs["json"] = {"name": {"name": f"guest-m{major}"}}
elif path == "/api/vcenter/vm/{vm}" and verb == "POST":
kwargs["json"] = {"action": "unregister"}
else:
kwargs["json"] = {"name": f"probe-{major}-{os.urandom(2).hex()}"}
if verb == "DELETE" and path.endswith("{vm}"):
url = "/api/vcenter/vm/vm-missing-matrix"
response = await client.request(verb, url, **kwargs)
if response.status_code >= 500:
failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}")
assert failures == [], "\n".join(failures)
@@ -0,0 +1,135 @@
"""SOAP CreateVM / FindChild / guest filesystem REST parity."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _soap_login(client: AsyncClient) -> str:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200, login.text
session = login.headers.get("vmware-api-session-id")
assert session
return session
async def test_soap_create_vm_and_find_child(client: AsyncClient) -> None:
session = await _soap_login(client)
headers = {
"Content-Type": "text/xml",
"vmware-api-session-id": session,
"Cookie": f'vmware_soap_session="{session}"',
}
create = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<CreateVM_Task xmlns="urn:vim25">
<_this type="Folder">group-v23</_this>
<config>
<name>soap-create-lab</name>
<guestId>otherGuest64</guestId>
<numCPUs>2</numCPUs>
<memoryMB>1024</memoryMB>
<files><vmPathName>[datastore1]</vmPathName></files>
</config>
<pool type="ResourcePool">resgroup-22</pool>
<host type="HostSystem">host-11</host>
</CreateVM_Task>
</soapenv:Body>
</soapenv:Envelope>""",
headers=headers,
)
assert create.status_code == 200, create.text
assert "CreateVM_TaskResponse" in create.text
assert "task-" in create.text
find = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<FindChild xmlns="urn:vim25">
<_this type="SearchIndex">SearchIndex</_this>
<entity type="Folder">group-v23</entity>
<name>soap-create-lab</name>
</FindChild>
</soapenv:Body>
</soapenv:Envelope>""",
headers=headers,
)
assert find.status_code == 200, find.text
assert "VirtualMachine" in find.text
async def test_rest_guest_filesystem_roundtrip(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert login.status_code in {200, 201}
headers = {"vmware-api-session-id": login.json()}
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
vm = next(item["vm"] for item in vms.json() if item["name"] == "web-01")
put = await client.put(
f"/api/vcenter/vm/{vm}/guest/filesystem",
params={"path": "/tmp/fs-test"},
headers=headers,
json={"content": "hello-lab"},
)
assert put.status_code == 204
get = await client.get(
f"/api/vcenter/vm/{vm}/guest/filesystem",
params={"path": "/tmp/fs-test"},
headers=headers,
)
assert get.status_code == 200
assert get.json()["content"] == "hello-lab"
listing = await client.get(
f"/api/vcenter/vm/{vm}/guest/filesystem/files",
params={"path": "/tmp"},
headers=headers,
)
assert listing.status_code == 200
assert any(item["path"] == "/tmp/fs-test" for item in listing.json())
@@ -0,0 +1,165 @@
"""SOAP PropertyCollector / TaskManager fidelity tests."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _soap_login(client: AsyncClient) -> str:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
assert "LoginResponse" in login.text
cookie = login.headers.get("set-cookie") or ""
assert "vmware_soap_session" in cookie
return cookie.split(";")[0]
async def test_folder_child_entity_and_path(client: AsyncClient) -> None:
cookie = await _soap_login(client)
props = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveProperties xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<specSet>
<propSet><type>Folder</type><pathSet>childEntity</pathSet><pathSet>name</pathSet></propSet>
<objectSet><obj type="Folder">group-d1</obj></objectSet>
</specSet>
</RetrieveProperties>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert props.status_code == 200
assert "childEntity" in props.text
assert "datacenter-21" in props.text
path = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<FindByInventoryPath xmlns="urn:vim25">
<_this type="SearchIndex">SearchIndex</_this>
<inventoryPath>/Datacenters/Datacenter/vm/web-01</inventoryPath>
</FindByInventoryPath>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert path.status_code == 200
assert "vm-101" in path.text
async def test_wait_for_updates_version_and_power_task_id(client: AsyncClient) -> None:
cookie = await _soap_login(client)
first = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version></version>
</WaitForUpdatesEx>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert first.status_code == 200
assert "<version>1</version>" in first.text
assert "enter" in first.text
second = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version>1</version>
</WaitForUpdatesEx>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert second.status_code == 200
assert "enter" not in second.text
power = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<PowerOnVM_Task xmlns="urn:vim25">
<_this type="VirtualMachine">vm-104</_this>
</PowerOnVM_Task>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert power.status_code == 200
assert "task-" in power.text
assert "task-1<" not in power.text
about = await client.get("/sdk/about.do")
assert about.status_code == 200
assert "vCenter" in about.text
wsdl = await client.get("/sdk/vimService.wsdl")
assert "WaitForUpdatesEx" in wsdl.text
assert "CancelTask" in wsdl.text
async def test_legacy_rest_value_wrapper(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
headers = {"vmware-api-session-id": login.json()}
resp = await client.get("/rest/vcenter/vm", headers=headers, params={"limit": 2})
assert resp.status_code == 200
body = resp.json()
assert "value" in body
assert len(body["value"]) == 2