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 @@
|
||||
"""External client compatibility tests."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""CI gate: every declared method on majors 6-9 is callable without critical failures.
|
||||
|
||||
Critical = HTTP 501, server 5xx, unhandled exceptions, or emulator-limitation
|
||||
strings. Synthetic 4xx (missing object / incomplete payload) are allowed.
|
||||
Requires PostgreSQL via ``TEST_DATABASE_URL``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.surface_probe import run_probe
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_majors_surface_has_zero_critical_failures() -> None:
|
||||
results = await run_probe()
|
||||
assert len(results) == 4
|
||||
for item in results:
|
||||
version = item["version"]
|
||||
declared = item["declared"]
|
||||
assert item["implemented"] == declared, version
|
||||
assert item["verified"] == declared, version
|
||||
assert item["dimensions_min"] == declared, version
|
||||
assert item["failure_count"] == 0, f"{version} critical failures: {item.get('failures')}"
|
||||
by_verb = item["by_verb"]
|
||||
for verb, buckets in by_verb.items():
|
||||
assert buckets.get("unimplemented_501", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("unsupported_message", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("server_5xx", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("exception", 0) == 0, (version, verb, buckets)
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Group-level API smoke with real PostgreSQL persistence.
|
||||
|
||||
Exercises representative create/update/read paths per major API group so the
|
||||
surface verified ledger is backed by working handlers, not only route presence.
|
||||
Requires ``TEST_DATABASE_URL`` (same as other integration tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.migrations import migrate
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.main import create_app
|
||||
from app.simulation.seed import apply_seed, small_profile
|
||||
|
||||
pytestmark = pytest.mark.pve_stub
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
_BUNDLED_9 = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
|
||||
_NODE = "pve01"
|
||||
|
||||
|
||||
async def _prepare_database(url: str) -> None:
|
||||
connection = await asyncpg.connect(url)
|
||||
try:
|
||||
await migrate(connection)
|
||||
await apply_seed(connection, small_profile())
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def _login(client: AsyncClient) -> str:
|
||||
response = await client.post(
|
||||
"/api2/json/access/ticket",
|
||||
content="username=root%40pam&password=secret",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["username"] == "root@pam"
|
||||
ticket = data["ticket"]
|
||||
client.cookies.set("PVEAuthCookie", ticket)
|
||||
return str(data["CSRFPreventionToken"])
|
||||
|
||||
|
||||
async def _wait_task(client: AsyncClient, upid: str, *, node: str = _NODE) -> dict[str, Any]:
|
||||
for _ in range(100):
|
||||
response = await client.get(f"/api2/json/nodes/{node}/tasks/{upid}/status")
|
||||
assert response.status_code == 200, response.text
|
||||
task = cast(dict[str, Any], response.json()["data"])
|
||||
if task.get("status") == "stopped":
|
||||
return task
|
||||
await asyncio.sleep(0.05)
|
||||
raise AssertionError(f"task did not finish: {upid}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api_client() -> AsyncIterator[tuple[AsyncClient, str]]:
|
||||
url = os.environ["TEST_DATABASE_URL"]
|
||||
await _prepare_database(url)
|
||||
settings = Settings(
|
||||
database_url=SecretStr(url),
|
||||
contract_snapshot=_BUNDLED_9,
|
||||
compatibility_evidence=_EVIDENCE_9,
|
||||
ticket_signing_key=SecretStr("development-only-signing-key-change-me"),
|
||||
)
|
||||
|
||||
def database_factory(resolved: Settings) -> AsyncpgDatabase:
|
||||
return AsyncpgDatabase(resolved)
|
||||
|
||||
app = create_app(settings=settings, database_factory=database_factory)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
csrf = await _login(client)
|
||||
yield client, csrf
|
||||
|
||||
|
||||
async def test_access_group_realm_and_user_persist(api_client: tuple[AsyncClient, str]) -> None:
|
||||
client, csrf = api_client
|
||||
realm = "smoke-ldap"
|
||||
create_realm = await client.post(
|
||||
"/api2/json/access/domains",
|
||||
data={
|
||||
"realm": realm,
|
||||
"type": "ldap",
|
||||
"server1": "ldap.smoke.local",
|
||||
"base_dn": "dc=smoke,dc=local",
|
||||
"comment": "group smoke realm",
|
||||
},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert create_realm.status_code == 200, create_realm.text
|
||||
|
||||
listed = await client.get("/api2/json/access/domains")
|
||||
assert listed.status_code == 200
|
||||
names = {item["realm"] for item in listed.json()["data"]}
|
||||
assert realm in names
|
||||
|
||||
detail = await client.get(f"/api2/json/access/domains/{realm}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["data"]["type"] == "ldap"
|
||||
|
||||
user = "smoke-user@pam"
|
||||
create_user = await client.post(
|
||||
"/api2/json/access/users",
|
||||
data={"userid": user, "comment": "group smoke user", "enable": 1},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert create_user.status_code == 200, create_user.text
|
||||
got_user = await client.get(f"/api2/json/access/users/{user}")
|
||||
assert got_user.status_code == 200
|
||||
assert got_user.json()["data"]["userid"] == user
|
||||
|
||||
delete_realm = await client.delete(
|
||||
f"/api2/json/access/domains/{realm}",
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert delete_realm.status_code == 200, delete_realm.text
|
||||
|
||||
|
||||
async def test_qemu_group_create_config_and_power(api_client: tuple[AsyncClient, str]) -> None:
|
||||
client, csrf = api_client
|
||||
vmid = 9101
|
||||
create = await client.post(
|
||||
f"/api2/json/nodes/{_NODE}/qemu",
|
||||
data={
|
||||
"vmid": str(vmid),
|
||||
"name": "smoke-qemu",
|
||||
"cores": "1",
|
||||
"memory": "512",
|
||||
},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert create.status_code == 200, create.text
|
||||
upid = create.json()["data"]
|
||||
assert isinstance(upid, str) and upid.startswith("UPID:")
|
||||
task = await _wait_task(client, upid)
|
||||
assert task.get("exitstatus") == "OK"
|
||||
|
||||
config = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
|
||||
assert config.status_code == 200
|
||||
assert config.json()["data"]["name"] == "smoke-qemu"
|
||||
|
||||
update = await client.put(
|
||||
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config",
|
||||
data={"name": "smoke-qemu-renamed", "cores": "2"},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert update.status_code == 200, update.text
|
||||
config2 = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
|
||||
assert config2.json()["data"]["name"] == "smoke-qemu-renamed"
|
||||
assert int(config2.json()["data"]["cores"]) == 2
|
||||
|
||||
start = await client.post(
|
||||
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/start",
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert start.status_code == 200, start.text
|
||||
start_task = await _wait_task(client, start.json()["data"])
|
||||
assert start_task.get("exitstatus") == "OK"
|
||||
status = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/current")
|
||||
assert status.status_code == 200
|
||||
assert status.json()["data"]["status"] in {"running", "started"}
|
||||
|
||||
|
||||
async def test_lxc_group_create_and_status(api_client: tuple[AsyncClient, str]) -> None:
|
||||
client, csrf = api_client
|
||||
vmid = 9201
|
||||
create = await client.post(
|
||||
f"/api2/json/nodes/{_NODE}/lxc",
|
||||
data={
|
||||
"vmid": str(vmid),
|
||||
"hostname": "smoke-lxc",
|
||||
"ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
|
||||
"memory": "256",
|
||||
"rootfs": "local-lvm:4",
|
||||
},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert create.status_code == 200, create.text
|
||||
upid = create.json()["data"]
|
||||
task = await _wait_task(client, upid)
|
||||
assert task.get("exitstatus") == "OK"
|
||||
|
||||
config = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/config")
|
||||
assert config.status_code == 200
|
||||
cfg = config.json()["data"]
|
||||
assert "hostname" in cfg or cfg.get("hostname") == "smoke-lxc"
|
||||
|
||||
status = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/status/current")
|
||||
assert status.status_code == 200
|
||||
assert "status" in status.json()["data"]
|
||||
|
||||
|
||||
async def test_storage_and_cluster_groups_mutate(api_client: tuple[AsyncClient, str]) -> None:
|
||||
client, csrf = api_client
|
||||
|
||||
storages = await client.get("/api2/json/storage")
|
||||
if storages.status_code == 404:
|
||||
storages = await client.get(f"/api2/json/nodes/{_NODE}/storage")
|
||||
assert storages.status_code == 200, storages.text
|
||||
assert storages.json()["data"]
|
||||
|
||||
content = await client.get(f"/api2/json/nodes/{_NODE}/storage/local/content")
|
||||
assert content.status_code == 200, content.text
|
||||
assert isinstance(content.json()["data"], list)
|
||||
|
||||
resources = await client.get("/api2/json/cluster/resources")
|
||||
assert resources.status_code == 200
|
||||
assert resources.json()["data"]
|
||||
|
||||
notify = await client.post(
|
||||
"/api2/json/cluster/notifications/endpoints/gotify",
|
||||
data={
|
||||
"name": "smoke-gotify",
|
||||
"server": "https://gotify.smoke.local",
|
||||
"token": "smoke-token",
|
||||
},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert notify.status_code == 200, notify.text
|
||||
got = await client.get("/api2/json/cluster/notifications/endpoints/gotify/smoke-gotify")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["data"]["name"] == "smoke-gotify"
|
||||
# secret must not be echoed
|
||||
assert "token" not in got.json()["data"] or got.json()["data"].get("token") in {None, ""}
|
||||
|
||||
|
||||
async def test_sdn_and_node_ops_groups_persist(api_client: tuple[AsyncClient, str]) -> None:
|
||||
client, csrf = api_client
|
||||
|
||||
zone = await client.post(
|
||||
"/api2/json/cluster/sdn/zones",
|
||||
data={"zone": "smokecn", "type": "simple", "mtu": "1500"},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert zone.status_code == 200, zone.text
|
||||
zones = await client.get("/api2/json/cluster/sdn/zones")
|
||||
assert zones.status_code == 200
|
||||
names = {item.get("zone") or item.get("id") for item in zones.json()["data"]}
|
||||
assert "smokecn" in names
|
||||
|
||||
network_put = await client.put(
|
||||
f"/api2/json/nodes/{_NODE}/network",
|
||||
data={},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
# Apply/reload may return null/UPID; must not be 501.
|
||||
assert network_put.status_code == 200, network_put.text
|
||||
|
||||
dns = await client.get(f"/api2/json/nodes/{_NODE}/dns")
|
||||
assert dns.status_code == 200
|
||||
assert isinstance(dns.json()["data"], dict)
|
||||
|
||||
dns_put = await client.put(
|
||||
f"/api2/json/nodes/{_NODE}/dns",
|
||||
data={"search": "smoke.local", "dns1": "1.1.1.1"},
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
assert dns_put.status_code == 200, dns_put.text
|
||||
dns2 = await client.get(f"/api2/json/nodes/{_NODE}/dns")
|
||||
assert dns2.json()["data"].get("search") == "smoke.local" or "dns1" in dns2.json()["data"]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Unmodified proxmoxer HTTPS smoke flow."""
|
||||
|
||||
import os
|
||||
from threading import Event
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from proxmoxer import ProxmoxAPI, ResourceException # type: ignore[import-untyped]
|
||||
|
||||
pytestmark = pytest.mark.pve_stub
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.compatibility,
|
||||
pytest.mark.skipif(not os.getenv("PROXMOXER_HOST"), reason="running TLS simulator required"),
|
||||
]
|
||||
|
||||
|
||||
def wait_task(proxmox: Any, upid: str) -> dict[str, object]:
|
||||
for _attempt in range(100):
|
||||
task = proxmox.nodes("pve1").tasks(upid).status.get()
|
||||
if task["status"] == "stopped":
|
||||
return cast(dict[str, object], task)
|
||||
Event().wait(0.05)
|
||||
raise AssertionError("task did not finish")
|
||||
|
||||
|
||||
def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
proxmox = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="root@pam",
|
||||
password=os.getenv("PROXMOXER_PASSWORD", "secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
|
||||
assert proxmox.version.get()["version"] == "9.2.3"
|
||||
assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
|
||||
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
|
||||
|
||||
token_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="root@pam",
|
||||
token_name=os.getenv("PROXMOXER_TOKEN_NAME", "automation"),
|
||||
token_value=os.getenv("PROXMOXER_TOKEN_SECRET", "automation-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
assert any(node["node"] == "pve1" for node in token_api.nodes.get())
|
||||
|
||||
readonly_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="auditor@pve",
|
||||
token_name=os.getenv("PROXMOXER_READONLY_TOKEN_NAME", "readonly"),
|
||||
token_value=os.getenv("PROXMOXER_READONLY_TOKEN_SECRET", "readonly-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
assert readonly_api.nodes.get()
|
||||
assert readonly_api.nodes("pve1").status.get()["status"] == "online"
|
||||
assert readonly_api.nodes("pve1").qemu("101").config.get()["vmid"] == 101
|
||||
with pytest.raises(ResourceException) as denied:
|
||||
readonly_api.nodes("pve1").qemu("101").status.start.post()
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
token_endpoint = proxmox.access.users("root@pam").token("ephemeral")
|
||||
created = token_endpoint.post(comment="compatibility lifecycle", privsep=0)
|
||||
assert created["full-tokenid"] == "root@pam!ephemeral"
|
||||
ephemeral = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="root@pam",
|
||||
token_name=os.getenv("PROXMOXER_EPHEMERAL_TOKEN_NAME", "ephemeral"),
|
||||
token_value=created["value"],
|
||||
verify_ssl=False,
|
||||
)
|
||||
assert ephemeral.nodes.get()
|
||||
updated = token_endpoint.put(comment="updated", privsep=0)
|
||||
assert updated["comment"] == "updated"
|
||||
assert token_endpoint.get()["comment"] == "updated"
|
||||
token_endpoint.delete()
|
||||
with pytest.raises(ResourceException) as removed:
|
||||
ephemeral.nodes.get()
|
||||
assert removed.value.status_code == 401
|
||||
|
||||
storage_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="storage@pve",
|
||||
token_name=os.getenv("PROXMOXER_STORAGE_TOKEN_NAME", "storage"),
|
||||
token_value=os.getenv("PROXMOXER_STORAGE_TOKEN_SECRET", "storage-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
for vmid in ("101", "999999"):
|
||||
with pytest.raises(ResourceException) as hidden:
|
||||
storage_api.nodes("pve1").qemu(vmid).config.get()
|
||||
assert hidden.value.status_code == 403
|
||||
|
||||
create_upid = proxmox.nodes("pve1").qemu.post(
|
||||
vmid=150,
|
||||
name="created-by-proxmoxer",
|
||||
cores=2,
|
||||
memory=1024,
|
||||
agent=1,
|
||||
scsi0="local-lvm:vm-150-disk-0,size=8G",
|
||||
)
|
||||
with pytest.raises(ResourceException) as duplicate_create:
|
||||
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
|
||||
assert duplicate_create.value.status_code == 409
|
||||
assert wait_task(proxmox, create_upid)["exitstatus"] == "OK"
|
||||
created_config = proxmox.nodes("pve1").qemu("150").config.get()
|
||||
assert created_config["name"] == "created-by-proxmoxer"
|
||||
assert created_config["cores"] == 2
|
||||
|
||||
assert proxmox.nodes("pve1").qemu("150").config.put(name="sync-update", cores=3) is None
|
||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "sync-update"
|
||||
update_upid = proxmox.nodes("pve1").qemu("150").config.post(name="async-update", memory=2048)
|
||||
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
|
||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
|
||||
|
||||
disk_api = proxmox.nodes("pve1").qemu("150")
|
||||
assert disk_api.resize.put(disk="scsi0", size="+2G") is None
|
||||
assert "size=10G" in disk_api.config.get()["scsi0"]
|
||||
move_upid = disk_api.move_disk.post(disk="scsi0", storage="local")
|
||||
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
|
||||
assert disk_api.config.get()["scsi0"].startswith("local:")
|
||||
assert disk_api.pending.get() == []
|
||||
assert wait_task(proxmox, disk_api.status.start.post())["exitstatus"] == "OK"
|
||||
assert disk_api.agent.ping.post()["result"] == {}
|
||||
assert disk_api.agent.info.get()["result"]["version"] == "9.2.0-simulator"
|
||||
assert disk_api.agent("get-osinfo").get()["result"]["machine"] == "x86_64"
|
||||
assert disk_api.agent("get-host-name").get()["result"]["host-name"] == "async-update"
|
||||
assert disk_api.agent("network-get-interfaces").get()["result"][0]["name"] == "eth0"
|
||||
assert disk_api.agent("get-time").get()["result"]["seconds"] > 0
|
||||
assert wait_task(proxmox, disk_api.status.stop.post())["exitstatus"] == "OK"
|
||||
|
||||
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
|
||||
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
|
||||
assert wait_task(proxmox, snapshot_upid)["exitstatus"] == "OK"
|
||||
assert any(item["name"] == "baseline" for item in snapshots.get())
|
||||
baseline = snapshots("baseline")
|
||||
assert baseline.get()["description"] == "before change"
|
||||
assert baseline.config.put(description="stable baseline") is None
|
||||
assert baseline.config.get()["description"] == "stable baseline"
|
||||
assert proxmox.nodes("pve1").qemu("150").config.put(name="after-snapshot") is None
|
||||
rollback_upid = baseline.rollback.post()
|
||||
assert wait_task(proxmox, rollback_upid)["exitstatus"] == "OK"
|
||||
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
|
||||
snapshot_delete_upid = baseline.delete()
|
||||
assert wait_task(proxmox, snapshot_delete_upid)["exitstatus"] == "OK"
|
||||
assert not snapshots.get()
|
||||
|
||||
clone_upid = (
|
||||
proxmox.nodes("pve1").qemu("150").clone.post(newid=151, name="clone-by-proxmoxer", full=1)
|
||||
)
|
||||
assert wait_task(proxmox, clone_upid)["exitstatus"] == "OK"
|
||||
assert proxmox.nodes("pve1").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
|
||||
migration = proxmox.nodes("pve1").qemu("151").migrate
|
||||
assert migration.get(target="pve2")["local_disks"] == []
|
||||
migrate_upid = migration.post(target="pve2", online=0)
|
||||
assert wait_task(proxmox, migrate_upid)["exitstatus"] == "OK"
|
||||
assert proxmox.nodes("pve2").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
|
||||
clone_delete_upid = proxmox.nodes("pve2").qemu("151").delete()
|
||||
assert wait_task(proxmox, clone_delete_upid)["exitstatus"] == "OK"
|
||||
|
||||
delete_upid = proxmox.nodes("pve1").qemu("150").delete()
|
||||
assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK"
|
||||
with pytest.raises(ResourceException) as deleted_vm:
|
||||
proxmox.nodes("pve1").qemu("150").config.get()
|
||||
assert deleted_vm.value.status_code == 404
|
||||
|
||||
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
|
||||
operator_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
user="operator@pve",
|
||||
token_name=os.getenv("PROXMOXER_OPERATOR_TOKEN_NAME", "operator"),
|
||||
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
status_resource = operator_api.nodes("pve1").qemu("101").status
|
||||
|
||||
def run(operation: str, expected: str) -> None:
|
||||
upid = status_resource(operation).post()
|
||||
assert wait_task(operator_api, upid)["exitstatus"] == "OK"
|
||||
assert status_resource.current.get()["status"] == expected
|
||||
|
||||
if status_resource.current.get()["status"] == "stopped":
|
||||
run("start", "running")
|
||||
run("reboot", "running")
|
||||
run("reset", "running")
|
||||
run("suspend", "paused")
|
||||
run("resume", "running")
|
||||
run("shutdown", "stopped")
|
||||
run("start", "running")
|
||||
run("stop", "stopped")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Durable full-surface verified ledger for bundled Proxmox majors 6-9.
|
||||
|
||||
These tests stay offline (no TLS gateway). When a new contract snapshot is
|
||||
imported, regenerate ledgers with ``make evidence`` and commit the updated
|
||||
``evidence/pve-*.json`` files so this suite stays green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.evidence_gen import generate_all
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
pytestmark = pytest.mark.pve_stub
|
||||
|
||||
_BUNDLED_9 = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
|
||||
_MAJORS = (6, 7, 8, 9)
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
settings = Settings(
|
||||
contract_snapshot=_BUNDLED_9,
|
||||
compatibility_evidence=_EVIDENCE_9,
|
||||
)
|
||||
return create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("major", _MAJORS)
|
||||
async def test_hot_swap_reports_full_verified_surface(major: int) -> None:
|
||||
app = _app()
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
applied = await client.post("/ui/api/contract/apply", params={"major": major})
|
||||
assert applied.status_code == 200
|
||||
assert applied.json()["ok"] is True
|
||||
|
||||
report = await client.get("/admin/compatibility")
|
||||
assert report.status_code == 200
|
||||
body = report.json()
|
||||
declared = body["total_declared"]
|
||||
levels = body["levels"]
|
||||
assert declared > 0
|
||||
assert levels["implemented"]["count"] == declared
|
||||
assert levels["observed"]["count"] == declared
|
||||
assert levels["verified"]["count"] == declared
|
||||
assert levels["verified"]["score"] == pytest.approx(1.0)
|
||||
for name, dimension in (body.get("dimensions") or {}).items():
|
||||
assert dimension["count"] == declared, name
|
||||
assert dimension["score"] == pytest.approx(1.0), name
|
||||
assert len(body.get("classifications", {}).get("fully_compatible") or []) == declared
|
||||
|
||||
|
||||
def test_committed_evidence_matches_generator(tmp_path: Path) -> None:
|
||||
written = generate_all(out_dir=tmp_path)
|
||||
assert set(written) == {"6.4-15", "7.4-16", "8.4.5", "9.2.3"}
|
||||
for version, generated in written.items():
|
||||
committed = Path("evidence") / f"pve-{version}.json"
|
||||
assert committed.is_file(), f"missing committed ledger for {version}"
|
||||
assert generated.read_text(encoding="utf-8") == committed.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Optional pyvmomi SmartConnect smoke (skipped when package uninstalled)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("pyVim")
|
||||
pytest.importorskip("pyVmomi")
|
||||
|
||||
from pyVim.connect import Disconnect, SmartConnect # type: ignore[import-untyped]
|
||||
from pyVmomi import vim # type: ignore[import-untyped]
|
||||
|
||||
pytestmark = pytest.mark.compatibility
|
||||
|
||||
|
||||
def test_pyvmomi_inventory_and_power() -> None:
|
||||
host = os.getenv("VSPHERE_HOST", "simulator")
|
||||
port = int(os.getenv("VSPHERE_PORT", "8080"))
|
||||
try:
|
||||
si = SmartConnect(
|
||||
host=host,
|
||||
user="administrator@vsphere.local",
|
||||
pwd="VMware1!",
|
||||
port=port,
|
||||
disableSslCertValidation=True,
|
||||
)
|
||||
except Exception as error:
|
||||
pytest.skip(f"SmartConnect failed: {error}")
|
||||
try:
|
||||
content = si.RetrieveContent()
|
||||
assert content.about.name
|
||||
container = content.viewManager.CreateContainerView(
|
||||
content.rootFolder, [vim.VirtualMachine], True
|
||||
)
|
||||
vms = list(container.view)
|
||||
assert len(vms) >= 5
|
||||
target = next((vm for vm in vms if vm.name == "app-01"), vms[0])
|
||||
if target.runtime.powerState != vim.VirtualMachinePowerState.poweredOn:
|
||||
task = target.PowerOn()
|
||||
assert task is not None
|
||||
finally:
|
||||
Disconnect(si)
|
||||
Reference in New Issue
Block a user