"""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 extras_scale: int = 1 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, extra_vm_folders: int = 0, ) -> 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(extra_vm_folders): specs.append( ObjectSpec( f"group-v{103 + index}", "Folder", f"team-{index + 1:02d}", "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, datastore_count: int = 4, folder_choices: tuple[str, ...] | None = None, ) -> 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)) folders = folder_choices or ("group-v100", "group-v101", "group-v23") folder = folders[index % len(folders)] guest = GUEST_OS[index % len(GUEST_OS)] ds_index = 1 + (index % max(1, datastore_count)) 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), ) @dataclass(frozen=True, slots=True) class ProfileSize: """Canonical lab sizes: hosts / VMs / datastores / networks / platform extras scale.""" name: str host_count: int vm_count: int datastore_count: int network_count: int extras_scale: int # Proportional inventory tiers shown in the Web UI DATA panel. PROFILE_SIZES: dict[str, ProfileSize] = { # Reset / unload target — cookbook-only inventory. "minimal": ProfileSize( "minimal", host_count=3, vm_count=5, datastore_count=1, network_count=1, extras_scale=1 ), "small": ProfileSize("small", host_count=3, vm_count=50, datastore_count=2, network_count=2, extras_scale=1), "large": ProfileSize( "large", host_count=10, vm_count=1000, datastore_count=4, network_count=4, extras_scale=2 ), "big": ProfileSize("big", host_count=20, vm_count=2000, datastore_count=8, network_count=8, extras_scale=4), } def _named_lab_vms() -> list[ObjectSpec]: """Stable cookbook VMs (vm-101..vm-105) present in every profile.""" 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 vms def _build_sized_profile(size: ProfileSize) -> VsphereSeedProfile: if size.host_count < 1 or size.vm_count < 1: raise ValueError("host_count and vm_count must be positive") # Folders scale with extras: minimal/small=0, large=2, big=6. extra_vm_folders = max(0, (size.extras_scale - 1) * 2) objects = _topology( host_count=size.host_count, datastore_count=size.datastore_count, network_count=size.network_count, extra_vm_folders=extra_vm_folders, ) named = _named_lab_vms() # Minimal / single-datastore profiles still reference datastore-31. if size.datastore_count < 1: raise ValueError("datastore_count must be positive") folder_choices = ( "group-v100", "group-v101", "group-v23", *(f"group-v{103 + i}" for i in range(extra_vm_folders)), ) vms: list[ObjectSpec] = list(named) if size.vm_count > len(named): vms.extend( _vm_spec( index, host_count=size.host_count, datastore_count=size.datastore_count, folder_choices=folder_choices, ) for index in range(len(named) + 1, size.vm_count + 1) ) elif size.vm_count < len(named): vms = vms[: size.vm_count] return VsphereSeedProfile( name=size.name, objects=tuple(objects + vms), credentials=lab_credentials(), permissions=lab_permissions(), host_count=size.host_count, vm_count=len(vms), extras_scale=size.extras_scale, ) def minimal_vsphere_profile() -> VsphereSeedProfile: """Reset target: 3 hosts · 5 cookbook VMs · 1 datastore · 1 network.""" return _build_sized_profile(PROFILE_SIZES["minimal"]) def small_vsphere_profile() -> VsphereSeedProfile: """Lab tier: 3 hosts · 50 VMs · 2 datastores · 2 networks.""" return _build_sized_profile(PROFILE_SIZES["small"]) def large_vsphere_profile(*, host_count: int = 10, vm_count: int = 1000) -> VsphereSeedProfile: """Lab tier: 10 hosts · 1000 VMs (defaults); kwargs keep Makefile overrides.""" size = PROFILE_SIZES["large"] if host_count == size.host_count and vm_count == size.vm_count: return _build_sized_profile(size) # Custom scale: keep datastore/network proportion to hosts (≈0.4× hosts, min 2). datastore_count = max(2, round(host_count * 0.4)) network_count = max(2, round(host_count * 0.4)) return _build_sized_profile( ProfileSize( name="large", host_count=host_count, vm_count=vm_count, datastore_count=datastore_count, network_count=network_count, extras_scale=max(1, datastore_count // 2), ) ) def big_vsphere_profile() -> VsphereSeedProfile: """Lab tier: 20 hosts · 2000 VMs · 8 datastores · 8 networks.""" return _build_sized_profile(PROFILE_SIZES["big"]) def demo_cluster_vsphere_profile() -> VsphereSeedProfile: """Backward-compatible alias for ``big`` (UI / older docs used demo-cluster).""" return big_vsphere_profile() 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 in {"minimal", "mini", "reset"}: return minimal_vsphere_profile() if profile_name in {"small"}: return small_vsphere_profile() if profile_name in {"big", "demo-cluster", "demo", "enterprise"}: return big_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 infer_profile_hint(*, hosts: int, vms: int, datastores: int = 0) -> str: """Map live inventory counts back to a DATA-panel profile name.""" for size in PROFILE_SIZES.values(): if hosts == size.host_count and vms == size.vm_count: if datastores and datastores != size.datastore_count: continue return size.name if hosts >= 15 and vms >= 1500: return "big" if hosts >= 8 and vms >= 500: return "large" if hosts <= 4 and vms <= 10: return "minimal" if hosts <= 4: return "small" return "custom" def props_json(props: dict[str, Any]) -> str: return json.dumps(props)