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 @@
|
||||
"""PostgreSQL-backed integration tests."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user