Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.
This commit is contained in:
@@ -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 == ""
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user