Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.

This commit is contained in:
2026-07-18 08:46:39 +03:00
parent f8d3cbdd59
commit 63cc409424
71 changed files with 38380 additions and 796 deletions
@@ -26,7 +26,7 @@ async def client() -> AsyncClient:
)
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")
await seed_vsphere_inventory(app.state.database, force=True, profile="big")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
@@ -42,7 +42,7 @@ async def test_demo_cluster_api_state_and_inventory(client: AsyncClient) -> None
vm_list = await client.get("/api/vcenter/vm", headers=headers)
assert vm_list.status_code == 200
assert len(vm_list.json()) >= 1000
assert len(vm_list.json()) >= 2000
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
@@ -0,0 +1,228 @@
"""Deep-handler wire realism: seed non-empty, mutations, tasks, 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_deep_inventory_lists_and_details(client: AsyncClient) -> None:
headers = await _session(client)
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
assert len(hosts.json()) >= 3
host_id = hosts.json()[0]["host"]
host = await client.get(f"/api/vcenter/host/{host_id}", headers=headers)
assert host.status_code == 200
assert host.json()["name"]
stores = await client.get("/api/vcenter/datastore", headers=headers)
assert stores.status_code == 200
ds_id = stores.json()[0]["datastore"]
detail = await client.get(f"/api/vcenter/datastore/{ds_id}", headers=headers)
assert detail.status_code == 200
files = await client.get(f"/api/vcenter/datastore/{ds_id}/files", headers=headers)
assert files.status_code == 200
assert len(files.json()) >= 1
async def test_content_library_deep_get_not_stub(client: AsyncClient) -> None:
headers = await _session(client)
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert "lib-local-1" in libs.json()
local = await client.get("/api/content/local-library", headers=headers)
assert local.status_code == 200
assert "lib-local-1" in local.json()
info = await client.get("/api/content/library/lib-local-1", headers=headers)
assert info.status_code == 200
body = info.json()
assert body["id"] == "lib-local-1"
assert body["name"] == "Local Content"
assert body["type"] == "LOCAL"
assert "path" not in body # stub placeholder must not leak
local_info = await client.get("/api/content/local-library/lib-local-1", headers=headers)
assert local_info.status_code == 200
assert local_info.json()["id"] == "lib-local-1"
items = await client.get(
"/api/content/library/item",
params={"library_id": "lib-local-1"},
headers=headers,
)
assert items.status_code == 200
assert "item-ubuntu" in items.json()
item = await client.get("/api/content/library/item/item-ubuntu", headers=headers)
assert item.status_code == 200
assert item.json()["name"] == "ubuntu-22.04"
assert item.json()["library_id"] == "lib-local-1"
async def test_power_task_poll_and_soap_consistency(client: AsyncClient) -> None:
headers = await _session(client)
power = await client.post(
"/api/vcenter/vm/vm-104/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 200
task_id = power.json()["task"]
assert task_id.startswith("task-")
task = await client.get(f"/api/cis/tasks/{task_id}", headers=headers)
assert task.status_code == 200
payload = task.json()
assert payload["status"] == "SUCCEEDED"
assert payload["state"] == "SUCCEEDED"
assert isinstance(payload["description"], dict)
assert payload["description"]["default_message"]
assert isinstance(payload["progress"], dict)
assert payload["progress"]["completed"] == 100
assert payload["result"]["vm"] == "vm-104"
listed = await client.post(
"/api/cis/tasks",
params={"action": "list"},
headers=headers,
json={"filter_spec": {"tasks": [task_id]}},
)
assert listed.status_code == 200
assert task_id in listed.json()
state = await client.get("/api/vcenter/vm/vm-104/power", headers=headers)
assert state.json()["state"] == "POWERED_ON"
soap = 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 soap.status_code == 200
cookie = (soap.headers.get("set-cookie") or "").split(";")[0]
power_soap = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<PowerOffVM_Task xmlns="urn:vim25">
<_this type="VirtualMachine">vm-104</_this>
</PowerOffVM_Task>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert power_soap.status_code == 200
assert "task-" in power_soap.text
assert 'type="Task"' in power_soap.text
async def test_tagging_association_query_action(client: AsyncClient) -> None:
headers = await _session(client)
tags = await client.get("/api/cis/tagging/tag", headers=headers)
assert tags.status_code == 200
tag_id = tags.json()[0]
attach = await client.post(
"/api/cis/tagging/tag-association",
params={"action": "attach"},
headers=headers,
json={"tag_id": tag_id, "object_id": {"type": "VirtualMachine", "id": "vm-103"}},
)
assert attach.status_code == 204
listed = await client.post(
"/api/cis/tagging/tag-association",
params={"action": "list-attached-tags"},
headers=headers,
json={"object_id": {"type": "VirtualMachine", "id": "vm-103"}},
)
assert listed.status_code == 200
assert tag_id in listed.json()
async def test_folder_create_and_authz_permissions(client: AsyncClient) -> None:
headers = await _session(client)
folder = await client.post(
"/api/vcenter/folder",
headers=headers,
json={"name": "deep-repro-folder", "type": "VIRTUAL_MACHINE", "parent": "group-v23"},
)
assert folder.status_code == 200
assert folder.json().startswith("group-")
roles = await client.get("/api/vcenter/authorization/roles", headers=headers)
assert roles.status_code == 200
assert any(r["role"] == "Administrator" for r in roles.json())
perms = await client.get("/api/vcenter/authorization/permissions", headers=headers)
assert perms.status_code == 200
assert len(perms.json()) >= 1
async def test_ovf_deploy_accepts_official_target_fields(client: AsyncClient) -> None:
headers = await _session(client)
deploy = await client.post(
"/api/vcenter/ovf/library-item/item-ubuntu",
headers=headers,
json={
"target": {
"resource_pool_id": "resgroup-22",
"folder_id": "group-v23",
"host_id": "host-11",
"datastore_id": "datastore-31",
},
"deployment_spec": {"name": "ovf-deep-repro", "accept_all_EULA": True},
},
)
assert deploy.status_code == 200
body = deploy.json()
assert body["resource_id"]["type"] == "VirtualMachine"
assert body["resource_id"]["id"].startswith("vm-")
assert body["task"].startswith("task-")
vm = await client.get(f"/api/vcenter/vm/{body['resource_id']['id']}", headers=headers)
assert vm.status_code == 200
assert vm.json()["name"] == "ovf-deep-repro"
+26
View File
@@ -0,0 +1,26 @@
"""HEAD is served for GET Automation routes (contract matrix)."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.middleware import HeadAsGetMiddleware
def test_head_as_get_middleware_strips_body() -> None:
app = FastAPI()
app.add_middleware(HeadAsGetMiddleware)
@app.get("/api/vcenter/vm")
def list_vms() -> list[dict[str, str]]:
return [{"vm": "vm-101", "name": "web-01"}]
client = TestClient(app)
get = client.get("/api/vcenter/vm")
assert get.status_code == 200
assert get.json()[0]["vm"] == "vm-101"
head = client.head("/api/vcenter/vm")
assert head.status_code == 200
assert head.content in (b"", None) or head.text == ""
+33
View File
@@ -0,0 +1,33 @@
"""Tests for nested PARAM field extraction from body_example."""
from __future__ import annotations
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
def test_body_fields_from_example_flattens_nested_scalars() -> None:
fields = body_fields_from_example(
{
"name": "lab-vm",
"placement": {"host": "host-11", "folder": "group-v23"},
"cpu": {"count": 2, "cores_per_socket": 1},
"disks": [{"new_vmdk": {"name": "disk-0", "capacity": 1024}}],
}
)
by_name = {field["name"]: field for field in fields}
assert by_name["name"]["example"] == "lab-vm"
assert by_name["name"]["type"] == "string"
assert by_name["placement.host"]["example"] == "host-11"
assert by_name["cpu.count"]["type"] == "integer"
assert by_name["cpu.count"]["example"] == 2
assert by_name["disks.0.new_vmdk.name"]["example"] == "disk-0"
assert by_name["disks.0.new_vmdk.capacity"]["type"] == "integer"
assert "placement" not in by_name
assert "disks" not in by_name
def test_set_by_path_builds_nested_dicts() -> None:
root: dict = {}
set_by_path(root, "placement.host", "host-11")
set_by_path(root, "cpu.count", 2)
assert root == {"placement": {"host": "host-11"}, "cpu": {"count": 2}}
+30
View File
@@ -0,0 +1,30 @@
"""Seed spine objects must not be deletable by probe traffic."""
import pytest
from app.vsphere.domain.inventory_ops import _SEED_PROTECTED_MOIDS, _is_seed_host_or_named_vm
from app.vsphere.inventory import ManagedObject
def test_seed_spine_includes_root_pool_and_named_vms() -> None:
assert "resgroup-22" in _SEED_PROTECTED_MOIDS
assert "domain-c21" in _SEED_PROTECTED_MOIDS
assert "vm-101" in _SEED_PROTECTED_MOIDS
assert "network-41" in _SEED_PROTECTED_MOIDS
@pytest.mark.parametrize("moid", ["host-11", "host-20", "host-30"])
def test_seed_hosts_are_protected(moid: str) -> None:
obj = ManagedObject(moid=moid, type="HostSystem", name="esxi", parent_moid="domain-c21", props={})
assert _is_seed_host_or_named_vm(moid, obj)
def test_probe_host_pattern_not_protected() -> None:
obj = ManagedObject(
moid="host-probe",
type="HostSystem",
name="probe",
parent_moid="domain-c21",
props={},
)
assert not _is_seed_host_or_named_vm("host-probe", obj)
+76 -1
View File
@@ -1,6 +1,7 @@
"""Native vSphere console catalog tests."""
from app.vsphere.contracts.catalog import (
_param_index,
list_vsphere_majors,
vsphere_catalog_payload,
vsphere_method_payload,
@@ -45,4 +46,78 @@ def test_vsphere_method_payload_extracts_path_fields() -> None:
assert payload["implemented"] is True
assert len(payload["path_fields"]) == 1
assert payload["path_fields"][0]["name"] == "vm"
assert payload["resolved_path"] == "/api/vcenter/vm/vm-111"
assert payload["resolved_path"] == "/api/vcenter/vm/vm-101"
def test_param_index_loaded_from_openapi() -> None:
_param_index.cache_clear()
methods = _param_index()
assert "GET /api/vcenter/vm" in methods
assert "POST /api/vcenter/vm" in methods
assert len(methods) > 500
def test_vsphere_method_payload_uses_openapi_query_filters() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm",
verb="GET",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
query_names = {field["name"] for field in payload["query_fields"]}
assert query_names >= {
"vms",
"names",
"folders",
"datacenters",
"hosts",
"clusters",
"resource_pools",
"power_states",
}
# GET has no JSON body in the real Automation API.
assert payload["body_example"] == {}
def test_vsphere_method_payload_uses_openapi_create_spec() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm",
verb="POST",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
assert payload["body_example"]["guest_os"] == "OTHER_GUEST_64"
assert payload["body_example"]["name"] == "web-01"
assert isinstance(payload["body_example"]["placement"], dict)
assert payload["body_example"]["placement"]["host"] == "host-11"
assert payload["body_example"]["cpu"]["count"] == 2
assert payload["body_example"]["memory"]["size_mib"] == 2048
body_names = {field["name"] for field in payload["body_fields"]}
assert "guest_os" in body_names
assert "name" in body_names
assert "placement.host" in body_names
assert "placement.folder" in body_names
assert "cpu.count" in body_names
assert "memory.size_mib" in body_names
# Nested object keys themselves are not PARAM rows — only scalar leaves.
assert "placement" not in body_names
assert "cpu" not in body_names
# Create must not inherit ?action=clone pollution.
assert not any(field["name"] == "action" for field in payload["query_fields"])
def test_vsphere_method_payload_power_action_query() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm/{vm}/power",
verb="POST",
runtime_version="8.0.2",
)
assert payload["param_source"] == "openapi"
action = next(field for field in payload["query_fields"] if field["name"] == "action")
assert action["optional"] is False
assert set(action["enum"]) >= {"start", "stop", "reset", "suspend"}
# Params drawer merges query into body_fields for editing.
assert any(field["name"] == "action" for field in payload["body_fields"])
+76 -8
View File
@@ -1,27 +1,64 @@
"""vSphere seed profile shape tests (no database)."""
from app.vsphere.profiles import build_vsphere_profile, large_vsphere_profile, small_vsphere_profile
from app.vsphere.profiles import (
PROFILE_SIZES,
big_vsphere_profile,
build_vsphere_profile,
infer_profile_hint,
large_vsphere_profile,
minimal_vsphere_profile,
small_vsphere_profile,
)
from app.vsphere.security.authz import has_privilege, privileges_for_roles
def test_small_profile_has_named_vms() -> None:
profile = small_vsphere_profile()
def test_profile_sizes_table() -> None:
assert PROFILE_SIZES["minimal"].vm_count == 5
assert PROFILE_SIZES["small"].host_count == 3
assert PROFILE_SIZES["small"].vm_count == 50
assert PROFILE_SIZES["large"].host_count == 10
assert PROFILE_SIZES["large"].vm_count == 1000
assert PROFILE_SIZES["big"].host_count == 20
assert PROFILE_SIZES["big"].vm_count == 2000
def test_minimal_profile() -> None:
profile = minimal_vsphere_profile()
assert profile.name == "minimal"
assert profile.vm_count == 5
assert profile.host_count == 3
assert len([o for o in profile.objects if o.type == "Datastore"]) == 1
assert len([o for o in profile.objects if o.type == "Network"]) == 1
def test_small_profile_has_named_vms_and_scale() -> None:
profile = small_vsphere_profile()
assert profile.vm_count == 50
assert profile.host_count == 3
assert profile.extras_scale == 1
names = {obj.name for obj in profile.objects if obj.type == "VirtualMachine"}
assert {"web-01", "app-01", "db-01"} <= names
datastores = [obj for obj in profile.objects if obj.type == "Datastore"]
assert len(datastores) == 2
ds_ids = {d.moid for d in datastores}
for vm in profile.objects:
if vm.type != "VirtualMachine":
continue
assert vm.props.get("datastore") in ds_ids
def test_large_profile_1000_vms() -> None:
profile = large_vsphere_profile(host_count=10, vm_count=1000)
assert profile.vm_count == 1000
assert profile.host_count == 10
assert profile.extras_scale == 2
vms = [obj for obj in profile.objects if obj.type == "VirtualMachine"]
hosts = [obj for obj in profile.objects if obj.type == "HostSystem"]
folders = [obj for obj in profile.objects if obj.type == "Folder"]
assert len(vms) == 1000
assert len(hosts) == 10
# Named cookbooks survive at the front of large inventories.
assert len(folders) == 10 # 8 spine + 2 scaled
assert any(obj.name == "web-01" for obj in vms)
# Even spread across hosts
by_host: dict[str, int] = {}
for vm in vms:
host = str(vm.props.get("host"))
@@ -31,10 +68,41 @@ def test_large_profile_1000_vms() -> None:
assert max(by_host.values()) <= 110
def test_demo_cluster_profile() -> None:
profile = build_vsphere_profile("demo-cluster")
assert profile.vm_count == 1000
def test_big_profile_2000_vms() -> None:
profile = big_vsphere_profile()
assert profile.name == "big"
assert profile.vm_count == 2000
assert profile.host_count == 20
assert profile.extras_scale == 4
datastores = [obj for obj in profile.objects if obj.type == "Datastore"]
networks = [
obj
for obj in profile.objects
if obj.type in {"Network", "DistributedVirtualPortgroup"}
]
folders = [obj for obj in profile.objects if obj.type == "Folder"]
assert len(datastores) == 8
assert len(networks) == 8
assert len(folders) == 14 # 8 spine + 6 scaled
ds_ids = {d.moid for d in datastores}
for vm in profile.objects:
if vm.type != "VirtualMachine":
continue
assert vm.props.get("datastore") in ds_ids
def test_demo_cluster_aliases_big() -> None:
profile = build_vsphere_profile("demo-cluster")
assert profile.name == "big"
assert profile.vm_count == 2000
assert profile.host_count == 20
def test_infer_profile_hint() -> None:
assert infer_profile_hint(hosts=3, vms=5, datastores=1) == "minimal"
assert infer_profile_hint(hosts=3, vms=50, datastores=2) == "small"
assert infer_profile_hint(hosts=10, vms=1000, datastores=4) == "large"
assert infer_profile_hint(hosts=20, vms=2000, datastores=8) == "big"
def test_lab_credentials_include_readonly() -> None: