f8d3cbdd59
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.
469 lines
16 KiB
Python
469 lines
16 KiB
Python
"""Declarative vSphere inventory profiles (small lab / large cluster)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ObjectSpec:
|
|
moid: str
|
|
type: str
|
|
name: str
|
|
parent_moid: str | None
|
|
props: dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CredentialSpec:
|
|
username: str
|
|
password: str
|
|
roles: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PermissionSpec:
|
|
principal: str
|
|
role: str
|
|
entity_moid: str | None
|
|
propagate: bool = True
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VsphereSeedProfile:
|
|
name: str
|
|
objects: tuple[ObjectSpec, ...]
|
|
credentials: tuple[CredentialSpec, ...]
|
|
permissions: tuple[PermissionSpec, ...]
|
|
host_count: int
|
|
vm_count: int
|
|
|
|
|
|
POWER_CYCLE = ("POWERED_ON", "POWERED_ON", "POWERED_ON", "POWERED_OFF", "SUSPENDED")
|
|
GUEST_OS = ("UBUNTU_64", "CENTOS_7_64", "WINDOWS_9_64", "RHEL_8_64", "OTHER_GUEST_64")
|
|
ROLE_PREFIX = (
|
|
"web",
|
|
"app",
|
|
"db",
|
|
"cache",
|
|
"batch",
|
|
"jump",
|
|
"ci",
|
|
"mon",
|
|
"log",
|
|
"ml",
|
|
)
|
|
|
|
|
|
def _topology(
|
|
*,
|
|
host_count: int,
|
|
datastore_count: int = 4,
|
|
network_count: int = 3,
|
|
) -> list[ObjectSpec]:
|
|
specs: list[ObjectSpec] = [
|
|
ObjectSpec("group-d1", "Folder", "Datacenters", None, {"folder_type": "DATACENTER"}),
|
|
ObjectSpec(
|
|
"datacenter-21",
|
|
"Datacenter",
|
|
"Datacenter",
|
|
"group-d1",
|
|
{
|
|
"datastore_folder": "group-s23",
|
|
"host_folder": "group-h23",
|
|
"vm_folder": "group-v23",
|
|
"network_folder": "group-n23",
|
|
},
|
|
),
|
|
ObjectSpec("group-h23", "Folder", "host", "datacenter-21", {"folder_type": "HOST"}),
|
|
ObjectSpec(
|
|
"group-v23", "Folder", "vm", "datacenter-21", {"folder_type": "VIRTUAL_MACHINE"}
|
|
),
|
|
ObjectSpec(
|
|
"group-s23", "Folder", "datastore", "datacenter-21", {"folder_type": "DATASTORE"}
|
|
),
|
|
ObjectSpec("group-n23", "Folder", "network", "datacenter-21", {"folder_type": "NETWORK"}),
|
|
ObjectSpec(
|
|
"domain-c21",
|
|
"ClusterComputeResource",
|
|
"Cluster",
|
|
"group-h23",
|
|
{"drs_enabled": True, "ha_enabled": True, "resource_pool": "resgroup-22"},
|
|
),
|
|
ObjectSpec(
|
|
"resgroup-22",
|
|
"ResourcePool",
|
|
"Resources",
|
|
"domain-c21",
|
|
{"cpu_limit_mhz": -1, "memory_limit_mib": -1},
|
|
),
|
|
# Workload folders for realism
|
|
ObjectSpec(
|
|
"group-v100", "Folder", "production", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
|
|
),
|
|
ObjectSpec(
|
|
"group-v101", "Folder", "staging", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
|
|
),
|
|
ObjectSpec(
|
|
"group-v102", "Folder", "templates", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
|
|
),
|
|
]
|
|
for index in range(1, host_count + 1):
|
|
moid = f"host-{10 + index}"
|
|
specs.append(
|
|
ObjectSpec(
|
|
moid,
|
|
"HostSystem",
|
|
f"esxi{index:02d}.lab.local",
|
|
"domain-c21",
|
|
{
|
|
"connection_state": "CONNECTED",
|
|
"power_state": "POWERED_ON",
|
|
"cpu_cores": 32 if index % 3 else 64,
|
|
"cpu_mhz": 2500,
|
|
"memory_size_mib": 262144 if index % 2 else 524288,
|
|
"ip_address": f"192.168.1.{10 + index}",
|
|
"version": "8.0.2",
|
|
"cluster": "domain-c21",
|
|
"maintenance_mode": False,
|
|
"networking": {
|
|
"dns": {"servers": ["8.8.8.8", "1.1.1.1"], "domains": ["lab.local"]},
|
|
"routing": {"default_gateway": "192.168.1.1"},
|
|
"interfaces": [
|
|
{
|
|
"name": "vmk0",
|
|
"mac": f"00:50:56:00:{index:02x}:01",
|
|
"ipv4": {"address": f"192.168.1.{10 + index}", "prefix": 24},
|
|
}
|
|
],
|
|
},
|
|
"storage_devices": [
|
|
{
|
|
"device": f"naa.lab{index:04d}",
|
|
"display_name": f"Local Disk {index}",
|
|
"capacity": 1099511627776 * (1 + index % 3),
|
|
"ssd": index % 2 == 0,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
)
|
|
for index in range(1, datastore_count + 1):
|
|
moid = f"datastore-{30 + index}"
|
|
capacity = 1099511627776 * (1 + (index % 3))
|
|
specs.append(
|
|
ObjectSpec(
|
|
moid,
|
|
"Datastore",
|
|
f"ds-{index:02d}" if index > 1 else "datastore1",
|
|
"group-s23",
|
|
{
|
|
"type": "VMFS" if index % 2 else "NFS",
|
|
"capacity": capacity,
|
|
"free_space": capacity // 2,
|
|
"accessible": True,
|
|
"multiple_host_access": True,
|
|
},
|
|
)
|
|
)
|
|
specs.append(
|
|
ObjectSpec(
|
|
"network-41",
|
|
"Network",
|
|
"VM Network",
|
|
"group-n23",
|
|
{"type": "STANDARD_PORTGROUP"},
|
|
)
|
|
)
|
|
for index in range(2, network_count + 1):
|
|
specs.append(
|
|
ObjectSpec(
|
|
f"dvportgroup-{40 + index}",
|
|
"DistributedVirtualPortgroup",
|
|
f"dvpg-vlan{100 + index}",
|
|
"group-n23",
|
|
{"type": "DISTRIBUTED_PORTGROUP", "vlan_id": 100 + index},
|
|
)
|
|
)
|
|
specs.append(
|
|
ObjectSpec(
|
|
"dvs-51",
|
|
"VmwareDistributedVirtualSwitch",
|
|
"DSwitch",
|
|
"group-n23",
|
|
{"version": "8.0.0", "mtu": 9000},
|
|
)
|
|
)
|
|
return specs
|
|
|
|
|
|
def _vm_device_props(*, name: str, power: str, index: int, nic_mac: str) -> dict[str, Any]:
|
|
"""Shared hardware / guest fields for lab VMs (used by API surface + SOAP)."""
|
|
|
|
guest_ip = f"10.0.{(index // 250) % 250}.{(index % 250) or 1}"
|
|
return {
|
|
"nics": [
|
|
{
|
|
"key": "4000",
|
|
"value": {
|
|
"label": "Network adapter 1",
|
|
"mac": nic_mac,
|
|
"state": "CONNECTED" if power == "POWERED_ON" else "NOT_CONNECTED",
|
|
"type": "VMXNET3",
|
|
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
|
|
},
|
|
}
|
|
],
|
|
"disks": [
|
|
{
|
|
"key": "2000",
|
|
"value": {
|
|
"label": "Hard disk 1",
|
|
"capacity": 42949672960 + (index % 5) * 10737418240,
|
|
"type": "SCSI",
|
|
},
|
|
}
|
|
],
|
|
"cdroms": [
|
|
{
|
|
"cdrom": "3000",
|
|
"label": "CD/DVD drive 1",
|
|
"state": "CONNECTED",
|
|
"backing": {"type": "ISO_FILE", "iso_file": "[datastore1] ISO/ubuntu.iso"},
|
|
}
|
|
],
|
|
"floppies": [{"floppy": "8000", "state": "NOT_CONNECTED"}],
|
|
"serials": [{"port": "9000", "yield_on_poll": True}],
|
|
"parallels": [{"port": "10000", "yield_on_poll": True}],
|
|
"scsi_adapters": [
|
|
{"adapter": "1000", "type": "LSILOGIC", "sharing": "NONE", "pci_slot_number": 16}
|
|
],
|
|
"sata_adapters": [{"adapter": "15000", "bus": 0, "pci_slot_number": 33}],
|
|
"nvme_adapters": [{"adapter": "19000", "bus": 0, "pci_slot_number": 160}],
|
|
"boot": {
|
|
"type": "BIOS",
|
|
"delay": 0,
|
|
"retry": False,
|
|
"retry_delay": 10000,
|
|
"enter_setup_mode": False,
|
|
},
|
|
"boot_devices": [{"type": "CDROM"}, {"type": "DISK"}, {"type": "ETHERNET"}],
|
|
"guest_ip": guest_ip,
|
|
"guest_filesystems": {
|
|
"filesystems": {
|
|
"/": {"capacity": 42949672960, "free_space": 21474836480},
|
|
}
|
|
},
|
|
"guest_networking": {
|
|
"dns": {"ip_addresses": ["8.8.8.8"], "host_name": name, "domain_name": "lab.local"},
|
|
"ip": {
|
|
"ip_addresses": [
|
|
{
|
|
"ip_address": guest_ip,
|
|
"prefix_length": 24,
|
|
"state": "PREFERRED",
|
|
}
|
|
]
|
|
},
|
|
},
|
|
"customization": {
|
|
"name": name,
|
|
"status": "PENDING",
|
|
"spec": {"hostname": name, "domain": "lab.local"},
|
|
},
|
|
"tools": {
|
|
"auto_update_supported": True,
|
|
"install_attempted": True,
|
|
"run_state": "RUNNING" if power == "POWERED_ON" else "NOT_RUNNING",
|
|
"upgrade_policy": "MANUAL",
|
|
"version_number": 12320,
|
|
"version_status": "CURRENT",
|
|
},
|
|
"cpu": {"cores_per_socket": 1, "hot_add_enabled": False},
|
|
"memory": {"hot_add_enabled": False},
|
|
"identity": {
|
|
"name": name,
|
|
"instance_uuid": f"5029{index:04d}-0000-0000-0000-{index:012d}",
|
|
},
|
|
}
|
|
|
|
|
|
def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
|
|
moid = f"vm-{100 + index}"
|
|
role = ROLE_PREFIX[index % len(ROLE_PREFIX)]
|
|
name = f"{role}-{index:04d}"
|
|
power = POWER_CYCLE[index % len(POWER_CYCLE)]
|
|
host = f"host-{10 + (index % host_count) + 1}"
|
|
cpus = 1 + (index % 8)
|
|
memory = 1024 * (1 + (index % 16))
|
|
folder = ("group-v100", "group-v101", "group-v23")[index % 3]
|
|
guest = GUEST_OS[index % len(GUEST_OS)]
|
|
ds_index = 1 + (index % 4)
|
|
nic_tail = f"{(index % 250):02x}"
|
|
devices = _vm_device_props(
|
|
name=name,
|
|
power=power,
|
|
index=index,
|
|
nic_mac=f"00:50:56:01:{(index // 256) % 256:02x}:{nic_tail}",
|
|
)
|
|
return ObjectSpec(
|
|
moid,
|
|
"VirtualMachine",
|
|
name,
|
|
folder,
|
|
{
|
|
"power_state": power,
|
|
"cpu_count": cpus,
|
|
"memory_size_mib": memory,
|
|
"guest_OS": guest,
|
|
"hardware_version": "VMX_19",
|
|
"host": host,
|
|
"datastore": f"datastore-{30 + ds_index}",
|
|
"resource_pool": "resgroup-22",
|
|
"networks": ["network-41"],
|
|
"template": False,
|
|
"tools_status": "GUEST_TOOLS_RUNNING"
|
|
if power == "POWERED_ON"
|
|
else "GUEST_TOOLS_NOT_RUNNING",
|
|
**devices,
|
|
},
|
|
)
|
|
|
|
|
|
def lab_credentials() -> tuple[CredentialSpec, ...]:
|
|
return (
|
|
CredentialSpec("administrator@vsphere.local", "VMware1!", ("Administrator",)),
|
|
CredentialSpec("readonly@vsphere.local", "VMware1!", ("ReadOnly",)),
|
|
CredentialSpec("operator@vsphere.local", "VMware1!", ("VirtualMachinePowerUser",)),
|
|
CredentialSpec("vmadmin@vsphere.local", "VMware1!", ("VirtualMachineAdministrator",)),
|
|
)
|
|
|
|
|
|
def lab_permissions() -> tuple[PermissionSpec, ...]:
|
|
return (
|
|
PermissionSpec("administrator@vsphere.local", "Administrator", None, True),
|
|
PermissionSpec("readonly@vsphere.local", "ReadOnly", "datacenter-21", True),
|
|
PermissionSpec("operator@vsphere.local", "VirtualMachinePowerUser", "group-v23", True),
|
|
PermissionSpec("vmadmin@vsphere.local", "VirtualMachineAdministrator", "group-v23", True),
|
|
)
|
|
|
|
|
|
def small_vsphere_profile() -> VsphereSeedProfile:
|
|
"""Compact seed used by unit/integration tests (named VMs)."""
|
|
|
|
objects = _topology(host_count=3, datastore_count=2, network_count=2)
|
|
named = (
|
|
("web-01", "POWERED_ON", "host-11", 2, 4096),
|
|
("web-02", "POWERED_ON", "host-12", 2, 4096),
|
|
("db-01", "POWERED_ON", "host-13", 4, 8192),
|
|
("app-01", "POWERED_OFF", "host-11", 2, 2048),
|
|
("jumpbox", "SUSPENDED", "host-12", 1, 1024),
|
|
)
|
|
vms: list[ObjectSpec] = []
|
|
for index, (name, power, host, cpus, memory) in enumerate(named, start=1):
|
|
moid = f"vm-{100 + index}"
|
|
devices = _vm_device_props(
|
|
name=name,
|
|
power=power,
|
|
index=index,
|
|
nic_mac=f"00:50:56:01:00:{moid[-2:]}",
|
|
)
|
|
devices["identity"]["instance_uuid"] = f"5029{moid[-3:]}-0000-0000-0000-000000000000"
|
|
vms.append(
|
|
ObjectSpec(
|
|
moid,
|
|
"VirtualMachine",
|
|
name,
|
|
"group-v23",
|
|
{
|
|
"power_state": power,
|
|
"cpu_count": cpus,
|
|
"memory_size_mib": memory,
|
|
"guest_OS": "UBUNTU_64",
|
|
"hardware_version": "VMX_19",
|
|
"host": host,
|
|
"datastore": "datastore-31",
|
|
"resource_pool": "resgroup-22",
|
|
"networks": ["network-41"],
|
|
"template": False,
|
|
"tools_status": "GUEST_TOOLS_RUNNING"
|
|
if power == "POWERED_ON"
|
|
else "GUEST_TOOLS_NOT_RUNNING",
|
|
**devices,
|
|
},
|
|
)
|
|
)
|
|
return VsphereSeedProfile(
|
|
name="small",
|
|
objects=tuple(objects + vms),
|
|
credentials=lab_credentials(),
|
|
permissions=lab_permissions(),
|
|
host_count=3,
|
|
vm_count=5,
|
|
)
|
|
|
|
|
|
def large_vsphere_profile(*, host_count: int = 10, vm_count: int = 1000) -> VsphereSeedProfile:
|
|
if host_count < 1 or vm_count < 1:
|
|
raise ValueError("host_count and vm_count must be positive")
|
|
objects = _topology(host_count=host_count, datastore_count=4, network_count=4)
|
|
# Keep first five named VMs for cookbook / smoke compatibility.
|
|
base = small_vsphere_profile()
|
|
named_vms = [obj for obj in base.objects if obj.type == "VirtualMachine"]
|
|
generated = [_vm_spec(index, host_count=host_count) for index in range(6, vm_count + 1)]
|
|
# Ensure first 5 from small keep stable ids/names; replace generated slots 1-5.
|
|
vms = list(named_vms)
|
|
if vm_count > 5:
|
|
vms.extend(generated)
|
|
elif vm_count < 5:
|
|
vms = vms[:vm_count]
|
|
return VsphereSeedProfile(
|
|
name="large",
|
|
objects=tuple(objects + vms),
|
|
credentials=lab_credentials(),
|
|
permissions=lab_permissions(),
|
|
host_count=host_count,
|
|
vm_count=len(vms),
|
|
)
|
|
|
|
|
|
def demo_cluster_vsphere_profile() -> VsphereSeedProfile:
|
|
"""Enterprise-shaped cluster: 20 hosts, 1000 VMs (aligned with Proxmox demo-cluster)."""
|
|
|
|
profile = large_vsphere_profile(host_count=20, vm_count=1000)
|
|
return VsphereSeedProfile(
|
|
name="demo-cluster",
|
|
objects=profile.objects,
|
|
credentials=profile.credentials,
|
|
permissions=profile.permissions,
|
|
host_count=profile.host_count,
|
|
vm_count=profile.vm_count,
|
|
)
|
|
|
|
|
|
def build_vsphere_profile(
|
|
name: str | None = None,
|
|
*,
|
|
large_hosts: int | None = None,
|
|
large_vms: int | None = None,
|
|
) -> VsphereSeedProfile:
|
|
profile_name = (name or os.getenv("SEED_VSPHERE_PROFILE") or "large").strip().lower()
|
|
hosts = (
|
|
large_hosts if large_hosts is not None else int(os.getenv("SEED_VSPHERE_LARGE_HOSTS", "10"))
|
|
)
|
|
vms = large_vms if large_vms is not None else int(os.getenv("SEED_VSPHERE_LARGE_VMS", "1000"))
|
|
if profile_name.lower() in {"small", "minimal"}:
|
|
return small_vsphere_profile()
|
|
if profile_name in {"demo-cluster", "demo", "enterprise"}:
|
|
return demo_cluster_vsphere_profile()
|
|
if profile_name == "large":
|
|
return large_vsphere_profile(host_count=hosts, vm_count=vms)
|
|
raise ValueError(f"unknown vSphere seed profile: {profile_name}")
|
|
|
|
|
|
def props_json(props: dict[str, Any]) -> str:
|
|
return json.dumps(props)
|