From 9f98f8fcb94fb425c5eb819e9cfa2e2efda03050 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 22 May 2026 18:54:36 +0100 Subject: [PATCH] Carry vSphere VM hardware config Project vCenter VM hardware, CPU, memory, and boot configuration through the VMware resource facet and shared vSphere details so operators can inspect virtual hardware posture as read-only monitoring context. --- .../internal/subsystems/storage-recovery.md | 22 ++- .../internal/subsystems/unified-resources.md | 15 +- .../ResourceDetailDrawer.discovery.test.ts | 61 ++++++ ...urceDetailDrawer.identity-runtime.test.tsx | 65 ++++++- .../resourceDetailDrawerVmwareModel.test.ts | 57 +++++- .../resourceDetailDiscoveryModel.ts | 51 ++++- .../resourceDetailDrawerVmwareModel.ts | 175 +++++++++++++++++- .../vmware/__tests__/vmwarePageModel.test.ts | 11 ++ .../src/features/vmware/vmwarePageModel.ts | 22 +++ .../src/types/__tests__/resource.test.ts | 29 +++ frontend-modern/src/types/resource.ts | 31 ++++ .../canonical_identity_test.go | 27 +++ internal/unifiedresources/clone.go | 34 ++++ internal/unifiedresources/clone_test.go | 32 ++++ .../unifiedresources/code_standards_test.go | 3 + internal/unifiedresources/registry.go | 18 ++ internal/unifiedresources/registry_test.go | 32 ++++ internal/unifiedresources/types.go | 35 ++++ internal/vmware/client_test.go | 55 ++++++ internal/vmware/client_topology.go | 138 +++++++++++++- internal/vmware/fixtures.go | 43 +++++ internal/vmware/provider.go | 114 ++++++++++++ internal/vmware/provider_test.go | 34 ++++ 23 files changed, 1082 insertions(+), 22 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6559be75e..900b269e5 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -736,7 +736,7 @@ a separate Docker-only or TrueNAS-local inventory path. 27. Keep alert-side recovery drill-ins on that same shared route-helper contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder instead of freezing alert-local recovery URLs or introducing another provider-shaped recovery handoff vocabulary. 28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport. 29. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface. -30. Keep VMware placement, guest-detail, VM snapshot-tree, VMware Tools, VM hardware Ethernet, and VM hardware disk enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, or capacity onto canonical VMware `agent` / `vm` / `storage` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, guest, snapshot-tree, VMware Tools, vNIC, or virtual disk fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice. +30. Keep VMware placement, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, and VM hardware disk enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, or capacity onto canonical VMware `agent` / `vm` / `storage` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, or virtual disk fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice. 31. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources. That same shared storage adapter boundary also owns canonical platform family vocabulary through the governed platform manifest. @@ -3157,18 +3157,20 @@ Storage and recovery must not infer VMware restore support, recovery rollups, or VMware-local protection semantics from the presence of those datastores or VM snapshot-read context on the shared pages. That same shared adapter floor also now carries richer VMware placement, -guest-detail, and VMware Tools metadata through the canonical `agent` / `vm` / -`storage` resources that storage and recovery can inspect on shared pages. +guest-detail, VM virtual-hardware, and VMware Tools metadata through the +canonical `agent` / `vm` / `storage` resources that storage and recovery can +inspect on shared pages. `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` may expose datacenter, cluster, folder, runtime-host, datastore-attachment, guest-hostname, and -guest-IP detail plus VMware Tools run-state, version, policy, install-attempt, -error, and guest-reboot context as inventory context, but those fields stay -descriptive only. Storage and recovery must not treat topology labels, -datastore attachments, guest identity, or VMware Tools posture as recovery -ownership, restore targeting, protection grouping, or a new VMware-local -storage/recovery taxonomy until a separately governed slice explicitly promotes -them into recovery contracts. +guest-IP detail plus VM virtual-hardware version, boot, CPU/memory hot-add, +VMware Tools run-state, version, policy, install-attempt, error, and +guest-reboot context as inventory context, but those fields stay descriptive +only. Storage and recovery must not treat topology labels, datastore +attachments, guest identity, virtual-hardware posture, or VMware Tools posture +as recovery ownership, restore targeting, protection grouping, or a new +VMware-local storage/recovery taxonomy until a separately governed slice +explicitly promotes them into recovery contracts. That same storage/recovery surface now also owns physical-disk live I/O presentation through the canonical chart boundary. Storage disk drawers may show read, write, busy, and SMART history, but they must route every chart diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 494354bb9..b4f3af245 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1151,9 +1151,10 @@ That same topology contract now also has a concrete projection seam. `internal/vmware/provider.go` must preserve VMware placement and identity detail on the shared `vmware` facet only: hosts may carry datacenter, compute-resource, cluster, folder, and attached-datastore metadata; VMs may -carry runtime-host, folder, resource-pool, datastore, guest-identity, VMware -Tools runtime status, and VM hardware Ethernet adapter plus VM hardware disk -metadata plus canonical parentage to the owning ESXi `agent`; datastores may +carry runtime-host, folder, resource-pool, datastore, guest-identity, VM +virtual-hardware configuration, VMware Tools runtime status, and VM hardware +Ethernet adapter plus VM hardware disk metadata plus canonical parentage to the +owning ESXi `agent`; datastores may carry datacenter/folder placement plus shared storage-node and workload consumer metadata through `storage.nodes`, `storage.consumerCount`, and `storage.topConsumers`. VMs may also carry VI JSON snapshot-tree context under @@ -1176,6 +1177,14 @@ request flag, requesting components, and request time. Those fields are operator-facing monitoring facts from vCenter's VM Tools API; they must not be promoted into lifecycle control, recovery status, workload identity, or a separate guest-runtime resource. +VM virtual-hardware configuration belongs under `vmware.hardware`, including +vCenter VM guest OS, instant-clone frozen flag, virtual hardware version, +hardware upgrade policy/version/status/error, boot type, EFI legacy boot, +network boot protocol, boot delay/retry/setup-mode flags, boot-device order, +CPU cores-per-socket and CPU hot-add/remove flags, and memory hot-add +increment/limit settings. Those fields are API-native read-only VM +configuration facts; they must not become Pulse VM-control authority, workload +identity aliases, recovery protection posture, or a separate hardware resource. Those enrichments must remain subordinate to shared `agent`, `vm`, and `storage` resources rather than becoming a VMware-only topology graph, recovery artifact, canonical identity alias, or separate provider detail drawer diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index bc1ab76d9..ae38748af 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -163,6 +163,67 @@ describe('toDiscoveryConfig', () => { }); }); + it('does not synthesize service discovery for VMware VMs without a backend target', () => { + const resource: Resource = { + ...baseResource(), + id: 'vm-270aaf3d37cc7501', + type: 'vm', + name: 'warehouse-api-01', + platformType: 'vmware-vsphere', + platformScopes: ['vmware-vsphere'], + sources: ['vmware'], + parentName: 'redacted by policy', + platformData: { + sources: ['vmware'], + vmware: { + connectionName: 'Lab vCenter', + clusterName: 'redacted by policy', + runtimeHostName: 'esxi-01.lab.local', + }, + }, + vmware: { + connectionName: 'Lab vCenter', + entityType: 'VirtualMachine', + managedObjectId: 'vm-201', + }, + }; + + expect(toDiscoveryConfig(resource)).toBeNull(); + }); + + it('still uses explicit backend discovery targets for VMware VMs', () => { + const resource: Resource = { + ...baseResource(), + id: 'vm-270aaf3d37cc7501', + type: 'vm', + name: 'warehouse-api-01', + platformType: 'vmware-vsphere', + platformScopes: ['vmware-vsphere'], + sources: ['vmware'], + discoveryTarget: { + resourceType: 'vm', + agentId: 'guest-agent-01', + resourceId: 'vm-201', + hostname: 'warehouse-api-01.internal', + }, + vmware: { + connectionName: 'Lab vCenter', + entityType: 'VirtualMachine', + managedObjectId: 'vm-201', + }, + }; + + expect(toDiscoveryConfig(resource)).toEqual({ + resourceType: 'vm', + agentId: 'guest-agent-01', + resourceId: 'vm-201', + hostname: 'warehouse-api-01.internal', + metadataKind: 'guest', + metadataId: 'vm-201', + targetLabel: 'guest', + }); + }); + it('prefers docker hostSourceId for app-container fallback agentId', () => { const resource: Resource = { ...baseResource(), diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index a6585fc8d..c4f022200 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -347,6 +347,33 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { guestRebootComponents: ['drivers'], guestRebootRequestTime: '2026-03-30T18:20:00Z', }, + hardware: { + guestOs: 'UBUNTU_64', + instantCloneFrozen: false, + version: 'VMX_20', + upgradePolicy: 'AFTER_CLEAN_SHUTDOWN', + upgradeVersion: 'VMX_21', + upgradeStatus: 'PENDING', + bootType: 'EFI', + efiLegacyBoot: false, + bootNetworkProtocol: 'IPV4', + bootDelayMilliseconds: 5000, + bootRetry: true, + bootRetryDelayMilliseconds: 10000, + enterSetupMode: false, + bootDevices: [ + { type: 'DISK', disks: ['2000'] }, + { type: 'ETHERNET', nic: '4000' }, + ], + cpuCoresPerSocket: 2, + cpuHotAddEnabled: true, + cpuHotRemoveEnabled: false, + memoryHotAddEnabled: true, + memoryHotAddIncrementMib: 256, + memoryHotAddLimitMib: 16384, + }, + cpuCount: 4, + memorySizeMib: 8192, activeAlarmCount: 1, activeAlarmSummary: 'Host fan degraded', recentTaskCount: 1, @@ -426,6 +453,33 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { guestRebootComponents: ['drivers'], guestRebootRequestTime: '2026-03-30T18:20:00Z', }, + hardware: { + guestOs: 'UBUNTU_64', + instantCloneFrozen: false, + version: 'VMX_20', + upgradePolicy: 'AFTER_CLEAN_SHUTDOWN', + upgradeVersion: 'VMX_21', + upgradeStatus: 'PENDING', + bootType: 'EFI', + efiLegacyBoot: false, + bootNetworkProtocol: 'IPV4', + bootDelayMilliseconds: 5000, + bootRetry: true, + bootRetryDelayMilliseconds: 10000, + enterSetupMode: false, + bootDevices: [ + { type: 'DISK', disks: ['2000'] }, + { type: 'ETHERNET', nic: '4000' }, + ], + cpuCoresPerSocket: 2, + cpuHotAddEnabled: true, + cpuHotRemoveEnabled: false, + memoryHotAddEnabled: true, + memoryHotAddIncrementMib: 256, + memoryHotAddLimitMib: 16384, + }, + cpuCount: 4, + memorySizeMib: 8192, activeAlarmCount: 1, activeAlarmSummary: 'Host fan degraded', recentTaskCount: 1, @@ -462,7 +516,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(getByTestId('resource-vmware-details-section')).toBeInTheDocument(); expect( getByText( - 'Lab VC · Read-only vCenter context · 2 snapshots · 1 vNIC · 1 disk · Tools reboot requested · 1 alarm · 1 task', + 'Lab VC · Read-only vCenter context · 2 snapshots · 1 vNIC · 1 disk · Hardware pending · Tools reboot requested · 1 alarm · 1 task', ), ).toBeInTheDocument(); expect(queryByText('Compute Cluster')).toBeNull(); @@ -478,6 +532,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(within(section).getByText('State')).toBeInTheDocument(); expect(within(section).getByText('Placement')).toBeInTheDocument(); expect(within(section).getByText('Guest')).toBeInTheDocument(); + expect(within(section).getByText('Virtual hardware')).toBeInTheDocument(); expect(within(section).getByText('VMware Tools')).toBeInTheDocument(); expect(within(section).getByText('Virtual disks')).toBeInTheDocument(); expect(within(section).getByText('Network')).toBeInTheDocument(); @@ -487,6 +542,14 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(within(section).getByText('Compute Cluster')).toBeInTheDocument(); expect(within(section).getByText('esxi-01.lab.local')).toBeInTheDocument(); expect(within(section).getByText('ubuntu64Guest')).toBeInTheDocument(); + expect(within(section).getByText('Hardware version')).toBeInTheDocument(); + expect(within(section).getByText('VMX 20')).toBeInTheDocument(); + expect(within(section).getByText('Upgrade status')).toBeInTheDocument(); + expect(within(section).getByText('Pending')).toBeInTheDocument(); + expect(within(section).getByText('CPU topology')).toBeInTheDocument(); + expect(within(section).getByText('4 vCPU · 2 cores/socket')).toBeInTheDocument(); + expect(within(section).getByText('Boot order')).toBeInTheDocument(); + expect(within(section).getByText('Disk 2000 -> Ethernet 4000')).toBeInTheDocument(); expect(within(section).getByText('Run state')).toBeInTheDocument(); expect(within(section).getByText('Running')).toBeInTheDocument(); expect(within(section).getByText('Version status')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts index cc9e0c7cf..354ed6949 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts @@ -50,6 +50,33 @@ describe('resourceDetailDrawerVmwareModel', () => { guestRebootComponents: ['drivers'], guestRebootRequestTime: '2026-03-30T18:20:00Z', }, + hardware: { + guestOs: 'UBUNTU_64', + instantCloneFrozen: false, + version: 'VMX_20', + upgradePolicy: 'AFTER_CLEAN_SHUTDOWN', + upgradeVersion: 'VMX_21', + upgradeStatus: 'PENDING', + bootType: 'EFI', + efiLegacyBoot: false, + bootNetworkProtocol: 'IPV4', + bootDelayMilliseconds: 5000, + bootRetry: true, + bootRetryDelayMilliseconds: 10000, + enterSetupMode: false, + bootDevices: [ + { type: 'DISK', disks: ['2000'] }, + { type: 'ETHERNET', nic: '4000' }, + ], + cpuCoresPerSocket: 2, + cpuHotAddEnabled: true, + cpuHotRemoveEnabled: false, + memoryHotAddEnabled: true, + memoryHotAddIncrementMib: 256, + memoryHotAddLimitMib: 16384, + }, + cpuCount: 4, + memorySizeMib: 8192, snapshotTree: [ { snapshot: 'snapshot-201', @@ -72,9 +99,37 @@ describe('resourceDetailDrawerVmwareModel', () => { }; expect(buildVMwareDetailsSummary('vm', vmware)).toBe( - 'Lab VC · Read-only vCenter context · 2 snapshots · 1 vNIC · 1 disk · Tools reboot requested', + 'Lab VC · Read-only vCenter context · 2 snapshots · 1 vNIC · 1 disk · Hardware pending · Tools reboot requested', ); + const hardware = buildVMwareDetailSections('vm', vmware).find( + (section) => section.id === 'hardware', + ); + + expect(hardware?.rows).toEqual([ + { label: 'Guest OS', value: 'Ubuntu 64' }, + { label: 'Hardware version', value: 'VMX 20' }, + { label: 'Upgrade status', value: 'Pending', tone: 'warning' }, + { label: 'Upgrade policy', value: 'After Clean Shutdown' }, + { label: 'Upgrade target', value: 'VMX 21' }, + { label: 'Instant clone frozen', value: 'No', tone: 'default' }, + { label: 'CPU topology', value: '4 vCPU · 2 cores/socket' }, + { label: 'CPU hot-add', value: 'Yes' }, + { label: 'CPU hot-remove', value: 'No' }, + { label: 'Memory size', value: '8 GB' }, + { label: 'Memory hot-add', value: 'Yes' }, + { label: 'Memory hot-add increment', value: '256 MB' }, + { label: 'Memory hot-add limit', value: '16 GB' }, + { label: 'Boot type', value: 'EFI' }, + { label: 'EFI legacy boot', value: 'No' }, + { label: 'Boot network protocol', value: 'IPv4' }, + { label: 'Boot delay', value: '5000 ms' }, + { label: 'Boot retry', value: 'Yes' }, + { label: 'Boot retry delay', value: '10000 ms' }, + { label: 'Enter setup mode', value: 'No', tone: 'default' }, + { label: 'Boot order', value: 'Disk 2000 -> Ethernet 4000' }, + ]); + const tools = buildVMwareDetailSections('vm', vmware).find((section) => section.id === 'tools'); expect(tools?.rows).toEqual([ diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index 49bd560ff..de7334071 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -43,12 +43,14 @@ type KubernetesPlatformData = { }; type PlatformData = { + sources?: string[]; agent?: { hostname?: string; }; docker?: DockerPlatformData; kubernetes?: KubernetesPlatformData; proxmox?: ProxmoxPlatformData; + vmware?: unknown; }; const asString = (value: unknown): string | undefined => @@ -57,6 +59,26 @@ const asString = (value: unknown): string | undefined => const asNumber = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; +const isDiscoveryLookupValue = (value: unknown): value is string => { + const candidate = asString(value); + return Boolean(candidate && candidate.toLowerCase() !== 'redacted by policy'); +}; + +const hasSource = (resource: Resource, platformData: PlatformData | undefined, source: string) => { + const normalizedSource = source.toLowerCase(); + return [ + ...(Array.isArray(resource.sources) ? resource.sources : []), + ...(Array.isArray(platformData?.sources) ? platformData.sources : []), + ...(Array.isArray(resource.platformScopes) ? resource.platformScopes : []), + ].some((candidate) => asString(candidate)?.toLowerCase() === normalizedSource); +}; + +const hasVMwareScope = (resource: Resource, platformData: PlatformData | undefined): boolean => + resource.platformType === 'vmware-vsphere' || + hasSource(resource, platformData, 'vmware') || + hasSource(resource, platformData, 'vmware-vsphere') || + Boolean(resource.vmware || platformData?.vmware); + const getPreferredHostLabel = (resource: Resource): string => getPreferredResourceHostname(resource) || getPreferredInfrastructureDisplayName(resource) || @@ -67,12 +89,15 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => const explicitDiscoveryAgentId = asString( (explicitDiscoveryTarget as { agentId?: unknown } | undefined)?.agentId, ); + const explicitDiscoveryResourceId = asString( + (explicitDiscoveryTarget as { resourceId?: unknown } | undefined)?.resourceId, + ); if ( explicitDiscoveryTarget && explicitDiscoveryTarget.resourceType && - explicitDiscoveryAgentId && - explicitDiscoveryTarget.resourceId + isDiscoveryLookupValue(explicitDiscoveryAgentId) && + isDiscoveryLookupValue(explicitDiscoveryResourceId) ) { const explicitResourceType = canonicalDiscoveryResourceType( explicitDiscoveryTarget.resourceType, @@ -104,10 +129,10 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => return { resourceType, agentId: explicitDiscoveryAgentId, - resourceId: explicitDiscoveryTarget.resourceId, + resourceId: explicitDiscoveryResourceId, hostname, metadataKind: isHostDiscovery ? 'agent' : 'guest', - metadataId: explicitDiscoveryTarget.resourceId, + metadataId: explicitDiscoveryResourceId, targetLabel, }; } @@ -177,6 +202,9 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => case 'pmg': case 'k8s-cluster': case 'k8s-node': + if (!isDiscoveryLookupValue(agentLookupId)) { + return null; + } return { resourceType: 'agent', agentId: agentLookupId, @@ -187,6 +215,12 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => targetLabel: 'agent', }; case 'vm': + if (hasVMwareScope(resource, platformData) && !(proxmoxNodeName && vmidResourceId)) { + return null; + } + if (!isDiscoveryLookupValue(workloadAgentId)) { + return null; + } return { resourceType: 'vm', agentId: workloadAgentId, @@ -198,6 +232,9 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => }; case 'system-container': case 'oci-container': + if (!isDiscoveryLookupValue(workloadAgentId)) { + return null; + } return { resourceType: 'system-container', agentId: workloadAgentId, @@ -208,6 +245,9 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => targetLabel: 'guest', }; case 'app-container': + if (!isDiscoveryLookupValue(workloadAgentId)) { + return null; + } return { resourceType: 'app-container', agentId: workloadAgentId, @@ -220,6 +260,9 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => case 'pod': case 'k8s-deployment': case 'k8s-service': + if (!isDiscoveryLookupValue(workloadAgentId)) { + return null; + } return { resourceType: 'pod', agentId: workloadAgentId, diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts index 01c5e5b8e..99a172186 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts @@ -1,5 +1,7 @@ import type { ResourceType, + ResourceVMwareBootDevice, + ResourceVMwareHardware, ResourceVMwareMeta, ResourceVMwareNetworkAdapter, ResourceVMwareSnapshot, @@ -16,7 +18,16 @@ export type ResourceDetailDrawerVMwareRow = { }; export type ResourceDetailDrawerVMwareSection = { - id: 'state' | 'placement' | 'guest' | 'tools' | 'disks' | 'network' | 'signals' | 'snapshots'; + id: + | 'state' + | 'placement' + | 'guest' + | 'hardware' + | 'tools' + | 'disks' + | 'network' + | 'signals' + | 'snapshots'; label: string; rows: ResourceDetailDrawerVMwareRow[]; }; @@ -39,6 +50,10 @@ const VMWARE_ENUM_ACRONYMS: Record = { OS: 'OS', VM: 'VM', VMDK: 'VMDK', + VMX: 'VMX', + EFI: 'EFI', + IPV4: 'IPv4', + IPV6: 'IPv6', }; const formatEnumLabel = (value?: string | null): string => { @@ -69,6 +84,16 @@ const formatCapacityBytes = (value?: number): string => { return `${size.toFixed(precision)} ${units[unitIndex]}`; }; +const formatMiB = (value?: number): string => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''; + return formatCapacityBytes(value * 1024 * 1024).replace(/\.0 ([A-Z]+)/, ' $1'); +}; + +const formatMilliseconds = (value?: number): string => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''; + return `${value} ms`; +}; + const countSnapshotTree = (snapshots?: ResourceVMwareSnapshot[]): number => (snapshots ?? []).reduce( (total, snapshot) => total + 1 + countSnapshotTree(snapshot.children), @@ -178,6 +203,148 @@ const filterNonEmptyRows = ( const getWarningTone = (hasWarning: boolean): ResourceDetailDrawerVMwareRowTone => hasWarning ? 'warning' : 'default'; +const hardwareSummary = (hardware?: ResourceVMwareHardware): string => { + if (!hardware) return ''; + const upgradeStatus = asTrimmedString(hardware.upgradeStatus); + if (upgradeStatus && !['NONE', 'OK'].includes(upgradeStatus.toUpperCase())) { + return `Hardware ${formatEnumLabel(upgradeStatus).toLowerCase()}`; + } + return formatEnumLabel(hardware.version); +}; + +const bootDeviceLabel = (device: ResourceVMwareBootDevice): string => { + const type = formatEnumLabel(device.type); + const details = [ + asTrimmedString(device.nic), + ...(device.disks ?? []).map((disk) => asTrimmedString(disk)), + ].filter(Boolean); + return details.length > 0 ? `${type} ${details.join(', ')}` : type; +}; + +const bootOrderLabel = (devices?: ResourceVMwareBootDevice[]): string => + (devices ?? []).map(bootDeviceLabel).filter(Boolean).join(' -> '); + +const cpuTopologyLabel = (vmware: ResourceVMwareMeta): string => { + const parts = []; + if ( + typeof vmware.cpuCount === 'number' && + Number.isFinite(vmware.cpuCount) && + vmware.cpuCount > 0 + ) { + parts.push(`${vmware.cpuCount} vCPU`); + } + if ( + typeof vmware.hardware?.cpuCoresPerSocket === 'number' && + Number.isFinite(vmware.hardware.cpuCoresPerSocket) && + vmware.hardware.cpuCoresPerSocket > 0 + ) { + parts.push(`${vmware.hardware.cpuCoresPerSocket} cores/socket`); + } + return parts.join(' · '); +}; + +const hardwareRows = (vmware?: ResourceVMwareMeta): ResourceDetailDrawerVMwareRow[] => { + if (!vmware?.hardware) return []; + const hardware = vmware.hardware; + return filterNonEmptyRows([ + { + label: 'Guest OS', + value: formatEnumLabel(hardware.guestOs), + }, + { + label: 'Hardware version', + value: formatEnumLabel(hardware.version), + }, + { + label: 'Upgrade status', + value: formatEnumLabel(hardware.upgradeStatus), + tone: getWarningTone( + Boolean(asTrimmedString(hardware.upgradeStatus)) && + !['NONE', 'OK'].includes(asTrimmedString(hardware.upgradeStatus).toUpperCase()), + ), + }, + { + label: 'Upgrade policy', + value: formatEnumLabel(hardware.upgradePolicy), + }, + { + label: 'Upgrade target', + value: formatEnumLabel(hardware.upgradeVersion), + }, + { + label: 'Upgrade error', + value: asTrimmedString(hardware.upgradeErrorMessage), + tone: 'warning', + }, + { + label: 'Instant clone frozen', + value: formatBoolLabel(hardware.instantCloneFrozen), + tone: getWarningTone(hardware.instantCloneFrozen === true), + }, + { + label: 'CPU topology', + value: cpuTopologyLabel(vmware), + }, + { + label: 'CPU hot-add', + value: formatBoolLabel(hardware.cpuHotAddEnabled), + }, + { + label: 'CPU hot-remove', + value: formatBoolLabel(hardware.cpuHotRemoveEnabled), + }, + { + label: 'Memory size', + value: formatMiB(vmware?.memorySizeMib), + }, + { + label: 'Memory hot-add', + value: formatBoolLabel(hardware.memoryHotAddEnabled), + }, + { + label: 'Memory hot-add increment', + value: formatMiB(hardware.memoryHotAddIncrementMib), + }, + { + label: 'Memory hot-add limit', + value: formatMiB(hardware.memoryHotAddLimitMib), + }, + { + label: 'Boot type', + value: formatEnumLabel(hardware.bootType), + }, + { + label: 'EFI legacy boot', + value: formatBoolLabel(hardware.efiLegacyBoot), + }, + { + label: 'Boot network protocol', + value: formatEnumLabel(hardware.bootNetworkProtocol), + }, + { + label: 'Boot delay', + value: formatMilliseconds(hardware.bootDelayMilliseconds), + }, + { + label: 'Boot retry', + value: formatBoolLabel(hardware.bootRetry), + }, + { + label: 'Boot retry delay', + value: formatMilliseconds(hardware.bootRetryDelayMilliseconds), + }, + { + label: 'Enter setup mode', + value: formatBoolLabel(hardware.enterSetupMode), + tone: getWarningTone(hardware.enterSetupMode === true), + }, + { + label: 'Boot order', + value: bootOrderLabel(hardware.bootDevices), + }, + ]); +}; + const toolsSummary = (tools?: ResourceVMwareTools): string => { if (!tools) return ''; if (tools.guestRebootRequested) return 'Tools reboot requested'; @@ -372,6 +539,10 @@ export const buildVMwareDetailsSummary = ( if (resourceType === 'vm' && virtualDiskCount > 0) { parts.push(formatCount(virtualDiskCount, 'disk')); } + const hardware = resourceType === 'vm' ? hardwareSummary(vmware.hardware) : ''; + if (hardware) { + parts.push(hardware); + } const tools = resourceType === 'vm' ? toolsSummary(vmware.tools) : ''; if (tools) { parts.push(tools); @@ -503,6 +674,7 @@ export const buildVMwareDetailSections = ( ]); const networkRows = resourceType === 'vm' ? networkAdapterRows(vmware.networkAdapters) : []; + const vmwareHardwareRows = resourceType === 'vm' ? hardwareRows(vmware) : []; const vmwareToolsRows = resourceType === 'vm' ? toolsRows(vmware.tools) : []; const diskRows = resourceType === 'vm' ? virtualDiskRows(vmware.virtualDisks) : []; @@ -540,6 +712,7 @@ export const buildVMwareDetailSections = ( { id: 'state', label: 'State', rows: stateRows }, { id: 'placement', label: 'Placement', rows: placementRows }, { id: 'guest', label: 'Guest', rows: guestRows }, + { id: 'hardware', label: 'Virtual hardware', rows: vmwareHardwareRows }, { id: 'tools', label: 'VMware Tools', rows: vmwareToolsRows }, { id: 'disks', label: 'Virtual disks', rows: diskRows }, { id: 'network', label: 'Network', rows: networkRows }, diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts index da9fa397f..984dfc17e 100644 --- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -139,6 +139,12 @@ describe('vmwarePageModel', () => { guestHostname: 'warehouse-api-01.internal', guestIpAddresses: ['10.42.10.21'], datastoreNames: ['nvme-primary'], + hardware: { + guestOs: 'UBUNTU_64', + version: 'VMX_20', + upgradeStatus: 'PENDING', + bootDevices: [{ type: 'ETHERNET', nic: '4000' }], + }, }, }); const attention = makeResource({ @@ -185,6 +191,11 @@ describe('vmwarePageModel', () => { (resource) => resource.id, ), ).toEqual(['vm-powered-off']); + expect( + filterVmwareVirtualMachines([poweredOn, attention, poweredOff], 'vmx 20', 'all').map( + (resource) => resource.id, + ), + ).toEqual(['vm-powered-on']); }); it('builds and filters vSphere health signals from resource incidents', () => { diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index ce2b31ee9..f9e944d77 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -132,6 +132,11 @@ const normalize = (value: unknown): string => const trimString = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); +const enumSearchValue = (value: unknown): string => { + const trimmed = trimString(value); + return trimmed ? `${trimmed} ${trimmed.replace(/[_-]/g, ' ')}` : ''; +}; + const normalizeToken = (value: unknown): string => normalize(value).replace(/[\s_-]/g, ''); const metadataString = ( @@ -739,6 +744,23 @@ const vmwareVirtualMachineSearchHaystack = (resource: Resource): string => ] .filter(Boolean) .join(' '), + [ + enumSearchValue(resource.vmware?.hardware?.guestOs), + enumSearchValue(resource.vmware?.hardware?.version), + enumSearchValue(resource.vmware?.hardware?.upgradePolicy), + enumSearchValue(resource.vmware?.hardware?.upgradeVersion), + enumSearchValue(resource.vmware?.hardware?.upgradeStatus), + resource.vmware?.hardware?.upgradeErrorMessage, + enumSearchValue(resource.vmware?.hardware?.bootType), + enumSearchValue(resource.vmware?.hardware?.bootNetworkProtocol), + resource.vmware?.hardware?.bootDevices + ?.map((device) => + [device.type, device.nic, device.disks?.join(' ')].filter(Boolean).join(' '), + ) + .join(' '), + ] + .filter(Boolean) + .join(' '), resource.vmware?.activeAlarmSummary, resource.vmware?.recentTaskSummary, ...(resource.tags ?? []), diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index fd7444400..b5cde7c5f 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -309,6 +309,31 @@ describe('Resource Helper Functions', () => { guestRebootComponents: ['drivers'], guestRebootRequestTime: '2026-03-30T18:20:00Z', }, + hardware: { + guestOs: 'UBUNTU_64', + instantCloneFrozen: false, + version: 'VMX_20', + upgradePolicy: 'AFTER_CLEAN_SHUTDOWN', + upgradeVersion: 'VMX_21', + upgradeStatus: 'PENDING', + bootType: 'EFI', + efiLegacyBoot: false, + bootNetworkProtocol: 'IPV4', + bootDelayMilliseconds: 5000, + bootRetry: true, + bootRetryDelayMilliseconds: 10000, + enterSetupMode: false, + bootDevices: [ + { type: 'DISK', disks: ['2000'] }, + { type: 'ETHERNET', nic: '4000' }, + ], + cpuCoresPerSocket: 2, + cpuHotAddEnabled: true, + cpuHotRemoveEnabled: false, + memoryHotAddEnabled: true, + memoryHotAddIncrementMib: 256, + memoryHotAddLimitMib: 16384, + }, snapshotTree: [ { snapshot: 'snapshot-101', @@ -344,6 +369,10 @@ describe('Resource Helper Functions', () => { expect(vmware.tools?.versionStatus).toBe('CURRENT'); expect(vmware.tools?.version).toBe('12.4.0'); expect(vmware.tools?.guestRebootComponents).toEqual(['drivers']); + expect(vmware.hardware?.version).toBe('VMX_20'); + expect(vmware.hardware?.upgradeStatus).toBe('PENDING'); + expect(vmware.hardware?.bootDevices?.[1]?.nic).toBe('4000'); + expect(vmware.hardware?.memoryHotAddLimitMib).toBe(16384); expect(vmware.snapshotTree?.[0]?.children?.[0]?.current).toBe(true); }); }); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 808993704..46c1da33b 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -894,6 +894,36 @@ export interface ResourceVMwareTools { guestRebootRequestTime?: string | number; } +export interface ResourceVMwareBootDevice { + type?: string; + nic?: string; + disks?: string[]; +} + +export interface ResourceVMwareHardware { + guestOs?: string; + instantCloneFrozen?: boolean; + version?: string; + upgradePolicy?: string; + upgradeVersion?: string; + upgradeStatus?: string; + upgradeErrorMessage?: string; + bootType?: string; + efiLegacyBoot?: boolean; + bootNetworkProtocol?: string; + bootDelayMilliseconds?: number; + bootRetry?: boolean; + bootRetryDelayMilliseconds?: number; + enterSetupMode?: boolean; + bootDevices?: ResourceVMwareBootDevice[]; + cpuCoresPerSocket?: number; + cpuHotAddEnabled?: boolean; + cpuHotRemoveEnabled?: boolean; + memoryHotAddEnabled?: boolean; + memoryHotAddIncrementMib?: number; + memoryHotAddLimitMib?: number; +} + export interface ResourceVMwareMeta { connectionId?: string; connectionName?: string; @@ -940,6 +970,7 @@ export interface ResourceVMwareMeta { networkAdapters?: ResourceVMwareNetworkAdapter[]; virtualDisks?: ResourceVMwareVirtualDisk[]; tools?: ResourceVMwareTools; + hardware?: ResourceVMwareHardware; } export interface ResourceAvailabilityMeta { diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index ad6e63094..fbafde3f0 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -474,6 +474,10 @@ func TestRefreshCanonicalIdentityIgnoresVMwarePlacementDetailAliases(t *testing. installAttempts := int64(1) toolsVersionNumber := int64(12352) guestRebootRequested := true + bootDelayMilliseconds := int64(5000) + bootRetry := true + coresPerSocket := int64(2) + memoryHotAddLimitMiB := int64(16384) resource := Resource{ ID: "vmware-vm-1", Type: ResourceTypeVM, @@ -535,6 +539,21 @@ func TestRefreshCanonicalIdentityIgnoresVMwarePlacementDetailAliases(t *testing. GuestRebootRequested: &guestRebootRequested, GuestRebootComponents: []string{"drivers"}, }, + Hardware: &VMwareVMHardwareData{ + GuestOS: "UBUNTU_64", + Version: "VMX_20", + UpgradePolicy: "AFTER_CLEAN_SHUTDOWN", + UpgradeVersion: "VMX_21", + UpgradeStatus: "PENDING", + UpgradeErrorMessage: "Virtual hardware upgrade failed", + BootType: "EFI", + BootNetworkProtocol: "IPV4", + BootDelayMilliseconds: &bootDelayMilliseconds, + BootRetry: &bootRetry, + BootDevices: []VMwareBootDeviceData{{Type: "DISK", Disks: []string{"2000"}}}, + CPUCoresPerSocket: &coresPerSocket, + MemoryHotAddLimitMiB: &memoryHotAddLimitMiB, + }, SnapshotTree: []VMwareSnapshotData{{ Snapshot: "snapshot-201", Name: "pre-upgrade", @@ -615,6 +634,14 @@ func TestRefreshCanonicalIdentityIgnoresVMwarePlacementDetailAliases(t *testing. "OPEN_VM_TOOLS", "RUNNING", "drivers", + "UBUNTU_64", + "VMX_20", + "AFTER_CLEAN_SHUTDOWN", + "VMX_21", + "PENDING", + "Virtual hardware upgrade failed", + "EFI", + "IPV4", } for _, disallowed := range disallowedAliases { for _, alias := range resource.Canonical.Aliases { diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 68b31f75b..cb1bc9a0b 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -271,6 +271,7 @@ func cloneVMwareData(in *VMwareData) *VMwareData { out.NetworkAdapters = cloneVMwareNetworkAdapterDataSlice(in.NetworkAdapters) out.VirtualDisks = cloneVMwareVirtualDiskDataSlice(in.VirtualDisks) out.Tools = cloneVMwareToolsData(in.Tools) + out.Hardware = cloneVMwareVMHardwareData(in.Hardware) return &out } @@ -332,6 +333,39 @@ func cloneVMwareToolsData(in *VMwareToolsData) *VMwareToolsData { return &out } +func cloneVMwareVMHardwareData(in *VMwareVMHardwareData) *VMwareVMHardwareData { + if in == nil { + return nil + } + out := *in + out.InstantCloneFrozen = cloneBoolPtr(in.InstantCloneFrozen) + out.EFILegacyBoot = cloneBoolPtr(in.EFILegacyBoot) + out.BootDelayMilliseconds = cloneInt64Ptr(in.BootDelayMilliseconds) + out.BootRetry = cloneBoolPtr(in.BootRetry) + out.BootRetryDelayMilliseconds = cloneInt64Ptr(in.BootRetryDelayMilliseconds) + out.EnterSetupMode = cloneBoolPtr(in.EnterSetupMode) + out.BootDevices = cloneVMwareBootDeviceDataSlice(in.BootDevices) + out.CPUCoresPerSocket = cloneInt64Ptr(in.CPUCoresPerSocket) + out.CPUHotAddEnabled = cloneBoolPtr(in.CPUHotAddEnabled) + out.CPUHotRemoveEnabled = cloneBoolPtr(in.CPUHotRemoveEnabled) + out.MemoryHotAddEnabled = cloneBoolPtr(in.MemoryHotAddEnabled) + out.MemoryHotAddIncrementMiB = cloneInt64Ptr(in.MemoryHotAddIncrementMiB) + out.MemoryHotAddLimitMiB = cloneInt64Ptr(in.MemoryHotAddLimitMiB) + return &out +} + +func cloneVMwareBootDeviceDataSlice(in []VMwareBootDeviceData) []VMwareBootDeviceData { + if in == nil { + return nil + } + out := make([]VMwareBootDeviceData, len(in)) + for i := range in { + out[i] = in[i] + out[i].Disks = cloneStringSlice(in[i].Disks) + } + return out +} + func clonePMGRelayDomainMetaSlice(in []PMGRelayDomainMeta) []PMGRelayDomainMeta { if in == nil { return nil diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go index 89d14f972..d8ccc3696 100644 --- a/internal/unifiedresources/clone_test.go +++ b/internal/unifiedresources/clone_test.go @@ -159,6 +159,11 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { installAttempts := int64(1) toolsVersionNumber := int64(12352) guestRebootRequested := true + instantCloneFrozen := false + bootDelayMilliseconds := int64(5000) + bootRetry := true + coresPerSocket := int64(2) + memoryHotAddLimitMiB := int64(16384) original := &Resource{ ID: "vmware-vm-1", VMware: &VMwareData{ @@ -199,6 +204,17 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { GuestRebootRequested: &guestRebootRequested, GuestRebootComponents: []string{"tools"}, }, + Hardware: &VMwareVMHardwareData{ + GuestOS: "UBUNTU_64", + InstantCloneFrozen: &instantCloneFrozen, + Version: "VMX_20", + UpgradeStatus: "PENDING", + BootDelayMilliseconds: &bootDelayMilliseconds, + BootRetry: &bootRetry, + BootDevices: []VMwareBootDeviceData{{Type: "DISK", Disks: []string{"2000"}}}, + CPUCoresPerSocket: &coresPerSocket, + MemoryHotAddLimitMiB: &memoryHotAddLimitMiB, + }, }, } @@ -214,6 +230,10 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { *cloned.VMware.Tools.AutoUpdateSupported = false *cloned.VMware.Tools.VersionNumber = 1 cloned.VMware.Tools.GuestRebootComponents[0] = "mutated" + *cloned.VMware.Hardware.InstantCloneFrozen = true + *cloned.VMware.Hardware.BootDelayMilliseconds = 1 + cloned.VMware.Hardware.BootDevices[0].Disks[0] = "mutated" + *cloned.VMware.Hardware.MemoryHotAddLimitMiB = 1 if original.VMware.SnapshotTree[0].Name != "pre-upgrade" { t.Fatalf("mutating cloned VMware snapshot should not affect original: %+v", original.VMware.SnapshotTree) @@ -248,6 +268,18 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { if original.VMware.Tools.GuestRebootComponents[0] != "tools" { t.Fatalf("mutating cloned VMware Tools reboot components should not affect original: %+v", original.VMware.Tools.GuestRebootComponents) } + if original.VMware.Hardware.InstantCloneFrozen == nil || *original.VMware.Hardware.InstantCloneFrozen { + t.Fatalf("mutating cloned VMware hardware frozen flag should not affect original: %+v", original.VMware.Hardware.InstantCloneFrozen) + } + if original.VMware.Hardware.BootDelayMilliseconds == nil || *original.VMware.Hardware.BootDelayMilliseconds != 5000 { + t.Fatalf("mutating cloned VMware hardware boot delay should not affect original: %+v", original.VMware.Hardware.BootDelayMilliseconds) + } + if original.VMware.Hardware.BootDevices[0].Disks[0] != "2000" { + t.Fatalf("mutating cloned VMware hardware boot devices should not affect original: %+v", original.VMware.Hardware.BootDevices) + } + if original.VMware.Hardware.MemoryHotAddLimitMiB == nil || *original.VMware.Hardware.MemoryHotAddLimitMiB != 16384 { + t.Fatalf("mutating cloned VMware hardware memory limit should not affect original: %+v", original.VMware.Hardware.MemoryHotAddLimitMiB) + } } // --- cloneProxmoxData --- diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index d42715b17..9cd660bc8 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -2008,11 +2008,14 @@ func TestCloneVMwareDataKeepsNestedRuntimeDetailsIsolated(t *testing.T) { "out.NetworkAdapters = cloneVMwareNetworkAdapterDataSlice(in.NetworkAdapters)", "out.VirtualDisks = cloneVMwareVirtualDiskDataSlice(in.VirtualDisks)", "out.Tools = cloneVMwareToolsData(in.Tools)", + "out.Hardware = cloneVMwareVMHardwareData(in.Hardware)", "out[i].CreatedAt = cloneTimePtr(in[i].CreatedAt)", "out[i].Children = cloneVMwareSnapshotDataSlice(in[i].Children)", "out[i].PCISlotNumber = cloneInt64Ptr(in[i].PCISlotNumber)", "out[i].CapacityBytes = cloneInt64Ptr(in[i].CapacityBytes)", "out.GuestRebootComponents = cloneStringSlice(in.GuestRebootComponents)", + "out.BootDevices = cloneVMwareBootDeviceDataSlice(in.BootDevices)", + "out[i].Disks = cloneStringSlice(in[i].Disks)", } for _, snippet := range requiredSnippets { if !strings.Contains(source, snippet) { diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 8bfafd975..6d30201cd 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -1832,6 +1832,24 @@ func mergeVMwareData(existing *VMwareData, incoming *VMwareData) *VMwareData { if incoming.SnapshotCount > 0 { merged.SnapshotCount = incoming.SnapshotCount } + if incoming.CurrentSnapshotID != "" { + merged.CurrentSnapshotID = incoming.CurrentSnapshotID + } + if len(incoming.SnapshotTree) > 0 { + merged.SnapshotTree = cloneVMwareSnapshotDataSlice(incoming.SnapshotTree) + } + if len(incoming.NetworkAdapters) > 0 { + merged.NetworkAdapters = cloneVMwareNetworkAdapterDataSlice(incoming.NetworkAdapters) + } + if len(incoming.VirtualDisks) > 0 { + merged.VirtualDisks = cloneVMwareVirtualDiskDataSlice(incoming.VirtualDisks) + } + if incoming.Tools != nil { + merged.Tools = cloneVMwareToolsData(incoming.Tools) + } + if incoming.Hardware != nil { + merged.Hardware = cloneVMwareVMHardwareData(incoming.Hardware) + } return &merged } diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 78aecd2e1..ab5f97747 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -761,6 +761,7 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes SnapshotCount: 1, } accessible := false + bootRetry := true incoming := &VMwareData{ ClusterName: "Cluster A", RuntimeHostName: "esxi-01.lab.local", @@ -773,6 +774,19 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes RecentTaskCount: 2, RecentTaskSummary: "vMotion task completed", SnapshotCount: 4, + CurrentSnapshotID: "snapshot-202", + SnapshotTree: []VMwareSnapshotData{{Snapshot: "snapshot-202", Name: "pre-maintenance"}}, + NetworkAdapters: []VMwareNetworkAdapterData{{NIC: "4000", NetworkName: "VM Network"}}, + VirtualDisks: []VMwareVirtualDiskData{{Disk: "2000", Label: "Hard disk 1"}}, + Tools: &VMwareToolsData{RunState: "RUNNING", VersionStatus: "CURRENT"}, + Hardware: &VMwareVMHardwareData{ + Version: "VMX_20", + BootRetry: &bootRetry, + BootDevices: []VMwareBootDeviceData{{ + Type: "DISK", + Disks: []string{"2000"}, + }}, + }, } merged := mergeVMwareData(existing, incoming) @@ -818,6 +832,24 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes if got := merged.GuestIPAddresses; !reflect.DeepEqual(got, []string{"10.0.0.21"}) { t.Fatalf("guest ip addresses = %#v", got) } + if got := merged.CurrentSnapshotID; got != "snapshot-202" { + t.Fatalf("current snapshot id = %q, want snapshot-202", got) + } + if got := merged.SnapshotTree; len(got) != 1 || got[0].Name != "pre-maintenance" { + t.Fatalf("snapshot tree = %#v", got) + } + if got := merged.NetworkAdapters; len(got) != 1 || got[0].NetworkName != "VM Network" { + t.Fatalf("network adapters = %#v", got) + } + if got := merged.VirtualDisks; len(got) != 1 || got[0].Label != "Hard disk 1" { + t.Fatalf("virtual disks = %#v", got) + } + if got := merged.Tools; got == nil || got.RunState != "RUNNING" { + t.Fatalf("tools = %#v", got) + } + if got := merged.Hardware; got == nil || got.Version != "VMX_20" || got.BootDevices[0].Disks[0] != "2000" { + t.Fatalf("hardware = %#v", got) + } } func TestResourceRegistryClonesCarryPolicyMetadata(t *testing.T) { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 824989758..a6ffe9234 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1040,6 +1040,7 @@ type VMwareData struct { NetworkAdapters []VMwareNetworkAdapterData `json:"networkAdapters,omitempty"` VirtualDisks []VMwareVirtualDiskData `json:"virtualDisks,omitempty"` Tools *VMwareToolsData `json:"tools,omitempty"` + Hardware *VMwareVMHardwareData `json:"hardware,omitempty"` } // VMwareSnapshotData contains one node in the vSphere VM snapshot tree. It is @@ -1118,6 +1119,40 @@ type VMwareToolsData struct { GuestRebootRequestTime string `json:"guestRebootRequestTime,omitempty"` } +// VMwareBootDeviceData contains one vCenter VM boot-device entry as read-only +// virtual hardware context. +type VMwareBootDeviceData struct { + Type string `json:"type,omitempty"` + NIC string `json:"nic,omitempty"` + Disks []string `json:"disks,omitempty"` +} + +// VMwareVMHardwareData contains vCenter VM virtual hardware, CPU, memory, and +// boot configuration facts as read-only VM context. +type VMwareVMHardwareData struct { + GuestOS string `json:"guestOs,omitempty"` + InstantCloneFrozen *bool `json:"instantCloneFrozen,omitempty"` + Version string `json:"version,omitempty"` + UpgradePolicy string `json:"upgradePolicy,omitempty"` + UpgradeVersion string `json:"upgradeVersion,omitempty"` + UpgradeStatus string `json:"upgradeStatus,omitempty"` + UpgradeErrorMessage string `json:"upgradeErrorMessage,omitempty"` + BootType string `json:"bootType,omitempty"` + EFILegacyBoot *bool `json:"efiLegacyBoot,omitempty"` + BootNetworkProtocol string `json:"bootNetworkProtocol,omitempty"` + BootDelayMilliseconds *int64 `json:"bootDelayMilliseconds,omitempty"` + BootRetry *bool `json:"bootRetry,omitempty"` + BootRetryDelayMilliseconds *int64 `json:"bootRetryDelayMilliseconds,omitempty"` + EnterSetupMode *bool `json:"enterSetupMode,omitempty"` + BootDevices []VMwareBootDeviceData `json:"bootDevices,omitempty"` + CPUCoresPerSocket *int64 `json:"cpuCoresPerSocket,omitempty"` + CPUHotAddEnabled *bool `json:"cpuHotAddEnabled,omitempty"` + CPUHotRemoveEnabled *bool `json:"cpuHotRemoveEnabled,omitempty"` + MemoryHotAddEnabled *bool `json:"memoryHotAddEnabled,omitempty"` + MemoryHotAddIncrementMiB *int64 `json:"memoryHotAddIncrementMib,omitempty"` + MemoryHotAddLimitMiB *int64 `json:"memoryHotAddLimitMib,omitempty"` +} + // TrueNASData contains TrueNAS-specific metadata for TrueNAS resources. type TrueNASData struct { Hostname string `json:"hostname,omitempty"` diff --git a/internal/vmware/client_test.go b/internal/vmware/client_test.go index 14d845be3..07ae24d73 100644 --- a/internal/vmware/client_test.go +++ b/internal/vmware/client_test.go @@ -75,6 +75,24 @@ func TestClientCollectInventoryEnrichesSignals(t *testing.T) { if vm.GuestHostname != "app-01.internal" || len(vm.GuestIPAddresses) != 1 || vm.GuestIPAddresses[0] != "10.0.0.21" { t.Fatalf("expected VM guest identity enrichment, got host=%q ips=%v", vm.GuestHostname, vm.GuestIPAddresses) } + if vm.Hardware == nil { + t.Fatalf("expected VM hardware enrichment") + } + if vm.Hardware.Version != "VMX_20" || vm.Hardware.UpgradePolicy != "AFTER_CLEAN_SHUTDOWN" || vm.Hardware.UpgradeStatus != "PENDING" { + t.Fatalf("unexpected VM hardware upgrade context: %+v", vm.Hardware) + } + if vm.Hardware.GuestOS != "UBUNTU_64" { + t.Fatalf("expected VM hardware guest OS, got %+v", vm.Hardware.GuestOS) + } + if vm.CPUCount != 6 || vm.Hardware.CPUCoresPerSocket == nil || *vm.Hardware.CPUCoresPerSocket != 3 { + t.Fatalf("expected VM CPU topology from VM detail, got cpu=%d hardware=%+v", vm.CPUCount, vm.Hardware) + } + if vm.MemorySizeMiB != 12288 || vm.Hardware.MemoryHotAddLimitMiB == nil || *vm.Hardware.MemoryHotAddLimitMiB != 24576 { + t.Fatalf("expected VM memory detail from VM detail, got memory=%d hardware=%+v", vm.MemorySizeMiB, vm.Hardware) + } + if len(vm.Hardware.BootDevices) != 2 || vm.Hardware.BootDevices[0].Disks[0] != "2000" || vm.Hardware.BootDevices[1].NIC != "4000" { + t.Fatalf("expected VM boot device order, got %+v", vm.Hardware.BootDevices) + } if vm.Tools == nil { t.Fatalf("expected VM tools enrichment") } @@ -215,6 +233,9 @@ func TestClientCollectInventoryPreservesBaseInventoryWhenOptionalEnrichmentDegra if vm.GuestHostname != "" || len(vm.GuestIPAddresses) != 0 { t.Fatalf("expected guest identity to stay empty after degraded topology read, got host=%q ips=%v", vm.GuestHostname, vm.GuestIPAddresses) } + if vm.Hardware == nil || vm.Hardware.Version != "VMX_20" { + t.Fatalf("expected hardware enrichment to survive degraded guest read, got %+v", vm.Hardware) + } if vm.Tools == nil || vm.Tools.RunState != "RUNNING" { t.Fatalf("expected tools enrichment to survive degraded guest read, got %+v", vm.Tools) } @@ -387,10 +408,44 @@ func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Ser mux.HandleFunc("/api/vcenter/vm/vm-201", func(w http.ResponseWriter, r *http.Request) { requireAutomationSession(t, r) writeJSON(w, map[string]any{ + "guest_os": "UBUNTU_64", + "power_state": "POWERED_ON", + "instant_clone_frozen": false, "identity": map[string]any{ "bios_uuid": "vm-bios-201", "instance_uuid": "vm-instance-201", }, + "hardware": map[string]any{ + "version": "VMX_20", + "upgrade_policy": "AFTER_CLEAN_SHUTDOWN", + "upgrade_version": "VMX_21", + "upgrade_status": "PENDING", + }, + "boot": map[string]any{ + "type": "EFI", + "efi_legacy_boot": false, + "network_protocol": "IPV4", + "delay": 5000, + "retry": true, + "retry_delay": 10000, + "enter_setup_mode": false, + }, + "boot_devices": []map[string]any{ + {"type": "DISK", "disks": []string{"2000"}}, + {"type": "ETHERNET", "nic": "4000"}, + }, + "cpu": map[string]any{ + "count": 6, + "cores_per_socket": 3, + "hot_add_enabled": true, + "hot_remove_enabled": false, + }, + "memory": map[string]any{ + "size_mib": 12288, + "hot_add_enabled": true, + "hot_add_increment_size_mib": 256, + "hot_add_limit_mib": 24576, + }, }) }) mux.HandleFunc("/api/vcenter/vm/vm-201/guest/identity", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/vmware/client_topology.go b/internal/vmware/client_topology.go index f7fc7d0ab..286db7f4c 100644 --- a/internal/vmware/client_topology.go +++ b/internal/vmware/client_topology.go @@ -14,8 +14,54 @@ type vcenterVMIdentity struct { InstanceUUID string `json:"instance_uuid"` } +type vcenterVMHardwareInfo struct { + Version string `json:"version"` + UpgradePolicy string `json:"upgrade_policy"` + UpgradeVersion string `json:"upgrade_version"` + UpgradeStatus string `json:"upgrade_status"` + UpgradeErrorMessage *vcenterVMLocalizableMessage `json:"upgrade_error"` +} + +type vcenterVMBootInfo struct { + Type string `json:"type"` + EFILegacyBoot *bool `json:"efi_legacy_boot"` + NetworkProtocol string `json:"network_protocol"` + Delay *int64 `json:"delay"` + Retry *bool `json:"retry"` + RetryDelay *int64 `json:"retry_delay"` + EnterSetupMode *bool `json:"enter_setup_mode"` +} + +type vcenterVMBootDeviceInfo struct { + Type string `json:"type"` + NIC string `json:"nic"` + Disks []string `json:"disks"` +} + +type vcenterVMCPUInfo struct { + Count *int64 `json:"count"` + CoresPerSocket *int64 `json:"cores_per_socket"` + HotAddEnabled *bool `json:"hot_add_enabled"` + HotRemoveEnabled *bool `json:"hot_remove_enabled"` +} + +type vcenterVMMemoryInfo struct { + SizeMiB *int64 `json:"size_mib"` + HotAddEnabled *bool `json:"hot_add_enabled"` + HotAddIncrementSizeMiB *int64 `json:"hot_add_increment_size_mib"` + HotAddLimitMiB *int64 `json:"hot_add_limit_mib"` +} + type vcenterVMInfo struct { - Identity *vcenterVMIdentity `json:"identity"` + GuestOS string `json:"guest_os"` + Identity *vcenterVMIdentity `json:"identity"` + PowerState string `json:"power_state"` + InstantCloneFrozen *bool `json:"instant_clone_frozen"` + Hardware *vcenterVMHardwareInfo `json:"hardware"` + Boot *vcenterVMBootInfo `json:"boot"` + BootDevices []vcenterVMBootDeviceInfo `json:"boot_devices"` + CPU *vcenterVMCPUInfo `json:"cpu"` + Memory *vcenterVMMemoryInfo `json:"memory"` } type vcenterVMGuestIdentity struct { @@ -343,6 +389,13 @@ func (c *Client) enrichVMTopology( vm.InstanceUUID = strings.TrimSpace(vmInfo.Identity.InstanceUUID) vm.BIOSUUID = strings.TrimSpace(vmInfo.Identity.BIOSUUID) } + if vmInfo != nil && vmInfo.CPU != nil && vmInfo.CPU.Count != nil && *vmInfo.CPU.Count > 0 { + vm.CPUCount = int(*vmInfo.CPU.Count) + } + if vmInfo != nil && vmInfo.Memory != nil && vmInfo.Memory.SizeMiB != nil && *vmInfo.Memory.SizeMiB > 0 { + vm.MemorySizeMiB = *vmInfo.Memory.SizeMiB + } + vm.Hardware = inventoryVMHardwareFromInfo(vmInfo) guestIdentity, err := c.collectVMGuestIdentity(ctx, automationSessionID, vm.VM) if issue, ok := classifyInventoryEnrichmentIssue("topology", "vm", vm.VM, err); ok { @@ -533,13 +586,94 @@ func (c *Client) collectVMAutomationInfo( return nil, nil } var payload vcenterVMInfo - path := fmt.Sprintf("/api/vcenter/vm/%s", vmID) + path := fmt.Sprintf("/api/vcenter/vm/%s", url.PathEscape(vmID)) if err := c.getAutomationJSON(ctx, automationSessionID, path, "vm detail", &payload); err != nil { return nil, err } return &payload, nil } +func inventoryVMHardwareFromInfo(info *vcenterVMInfo) *InventoryVMHardware { + if info == nil { + return nil + } + hardware := &InventoryVMHardware{ + GuestOS: strings.TrimSpace(info.GuestOS), + InstantCloneFrozen: cloneBoolPointer(info.InstantCloneFrozen), + } + if info.Hardware != nil { + hardware.Version = strings.TrimSpace(info.Hardware.Version) + hardware.UpgradePolicy = strings.TrimSpace(info.Hardware.UpgradePolicy) + hardware.UpgradeVersion = strings.TrimSpace(info.Hardware.UpgradeVersion) + hardware.UpgradeStatus = strings.TrimSpace(info.Hardware.UpgradeStatus) + hardware.UpgradeErrorMessage = localizableMessageText(info.Hardware.UpgradeErrorMessage) + } + if info.Boot != nil { + hardware.BootType = strings.TrimSpace(info.Boot.Type) + hardware.EFILegacyBoot = cloneBoolPointer(info.Boot.EFILegacyBoot) + hardware.BootNetworkProtocol = strings.TrimSpace(info.Boot.NetworkProtocol) + hardware.BootDelayMilliseconds = cloneInt64Pointer(info.Boot.Delay) + hardware.BootRetry = cloneBoolPointer(info.Boot.Retry) + hardware.BootRetryDelayMilliseconds = cloneInt64Pointer(info.Boot.RetryDelay) + hardware.EnterSetupMode = cloneBoolPointer(info.Boot.EnterSetupMode) + } + hardware.BootDevices = inventoryVMBootDevicesFromInfo(info.BootDevices) + if info.CPU != nil { + hardware.CPUCoresPerSocket = cloneInt64Pointer(info.CPU.CoresPerSocket) + hardware.CPUHotAddEnabled = cloneBoolPointer(info.CPU.HotAddEnabled) + hardware.CPUHotRemoveEnabled = cloneBoolPointer(info.CPU.HotRemoveEnabled) + } + if info.Memory != nil { + hardware.MemoryHotAddEnabled = cloneBoolPointer(info.Memory.HotAddEnabled) + hardware.MemoryHotAddIncrementMiB = cloneInt64Pointer(info.Memory.HotAddIncrementSizeMiB) + hardware.MemoryHotAddLimitMiB = cloneInt64Pointer(info.Memory.HotAddLimitMiB) + } + if inventoryVMHardwareEmpty(hardware) { + return nil + } + return hardware +} + +func inventoryVMBootDevicesFromInfo(devices []vcenterVMBootDeviceInfo) []InventoryVMBootDevice { + if len(devices) == 0 { + return nil + } + out := make([]InventoryVMBootDevice, 0, len(devices)) + for _, device := range devices { + out = append(out, InventoryVMBootDevice{ + Type: strings.TrimSpace(device.Type), + NIC: strings.TrimSpace(device.NIC), + Disks: cloneStringSlice(device.Disks), + }) + } + return out +} + +func inventoryVMHardwareEmpty(hardware *InventoryVMHardware) bool { + return hardware == nil || + (hardware.GuestOS == "" && + hardware.InstantCloneFrozen == nil && + hardware.Version == "" && + hardware.UpgradePolicy == "" && + hardware.UpgradeVersion == "" && + hardware.UpgradeStatus == "" && + hardware.UpgradeErrorMessage == "" && + hardware.BootType == "" && + hardware.EFILegacyBoot == nil && + hardware.BootNetworkProtocol == "" && + hardware.BootDelayMilliseconds == nil && + hardware.BootRetry == nil && + hardware.BootRetryDelayMilliseconds == nil && + hardware.EnterSetupMode == nil && + len(hardware.BootDevices) == 0 && + hardware.CPUCoresPerSocket == nil && + hardware.CPUHotAddEnabled == nil && + hardware.CPUHotRemoveEnabled == nil && + hardware.MemoryHotAddEnabled == nil && + hardware.MemoryHotAddIncrementMiB == nil && + hardware.MemoryHotAddLimitMiB == nil) +} + func (c *Client) collectVMGuestIdentity( ctx context.Context, automationSessionID string, diff --git a/internal/vmware/fixtures.go b/internal/vmware/fixtures.go index 2ea900141..fdeb125ab 100644 --- a/internal/vmware/fixtures.go +++ b/internal/vmware/fixtures.go @@ -211,6 +211,7 @@ func defaultFixturesPrimaryCluster( NetworkAdapters: fixtureVMNetworkAdapters("vm-201", "VM Network", true), VirtualDisks: fixtureVMVirtualDisks("vm-201", "nvme-primary", 128_000_000_000), Tools: fixtureVMTools("POWERED_ON", "green"), + Hardware: fixtureVMHardware("UBUNTU_64", "VMX_20", true), SnapshotTree: []InventoryVMSnapshot{{ Snapshot: "snapshot-201", Name: "pre-deploy-checkpoint", @@ -270,6 +271,7 @@ func defaultFixturesPrimaryCluster( NetworkAdapters: fixtureVMNetworkAdapters("vm-202", "Database Network", true), VirtualDisks: fixtureVMVirtualDisks("vm-202", "nvme-primary", 256_000_000_000), Tools: fixtureVMTools("POWERED_ON", "yellow"), + Hardware: fixtureVMHardware("RHEL_8_64", "VMX_19", true), SnapshotTree: []InventoryVMSnapshot{{ Snapshot: "snapshot-211", Name: "baseline", @@ -347,6 +349,7 @@ func defaultFixturesPrimaryCluster( NetworkAdapters: fixtureVMNetworkAdapters("vm-203", "Web Network", true), VirtualDisks: fixtureVMVirtualDisks("vm-203", "analytics-vsan", 96_000_000_000), Tools: fixtureVMTools("POWERED_ON", "green"), + Hardware: fixtureVMHardware("UBUNTU_64", "VMX_20", true), Metrics: &InventoryMetrics{ CPUPercent: float64Ptr(22.1), MemoryPercent: float64Ptr(51.2), @@ -388,6 +391,7 @@ func defaultFixturesPrimaryCluster( NetworkAdapters: fixtureVMNetworkAdapters("vm-204", "Platform Network", true), VirtualDisks: fixtureVMVirtualDisks("vm-204", "analytics-vsan", 192_000_000_000), Tools: fixtureVMTools("POWERED_ON", "green"), + Hardware: fixtureVMHardware("UBUNTU_64", "VMX_20", true), Metrics: &InventoryMetrics{ CPUPercent: float64Ptr(27.8), MemoryPercent: float64Ptr(59.6), @@ -429,6 +433,7 @@ func defaultFixturesPrimaryCluster( NetworkAdapters: fixtureVMNetworkAdapters("vm-205", "Utility Network", true), VirtualDisks: fixtureVMVirtualDisks("vm-205", "analytics-vsan", 80_000_000_000), Tools: fixtureVMTools("POWERED_ON", "green"), + Hardware: fixtureVMHardware("WINDOWS_2019_64", "VMX_19", true), Metrics: &InventoryMetrics{ CPUPercent: float64Ptr(16.8), MemoryPercent: float64Ptr(46.1), @@ -468,6 +473,7 @@ func defaultFixturesPrimaryCluster( OverallStatus: "gray", VirtualDisks: fixtureVMVirtualDisks("vm-206", "archive-tier", 64_000_000_000), Tools: fixtureVMTools("POWERED_OFF", "gray"), + Hardware: fixtureVMHardware("OTHER_64", "VMX_17", false), }, }, Datastores: []InventoryDatastore{ @@ -736,6 +742,7 @@ func appendEdgeClusterFixtures( NetworkAdapters: fixtureVMNetworkAdapters(v.ID, edgeVMNetworkName(v.Tier), v.PowerState == "POWERED_ON"), VirtualDisks: fixtureVMVirtualDisks(v.ID, firstNonEmptyTrimmed(dsNames...), int64(v.MemMiB)*1024*1024*4), Tools: fixtureVMTools(v.PowerState, v.Status), + Hardware: fixtureVMHardware("UBUNTU_64", "VMX_20", v.PowerState == "POWERED_ON"), } if v.PowerState == "POWERED_ON" { vm.Metrics = &InventoryMetrics{ @@ -884,6 +891,42 @@ func fixtureVMTools(powerState, status string) *InventoryVMTools { } } +func fixtureVMHardware(guestOS, version string, hotAddEnabled bool) *InventoryVMHardware { + instantCloneFrozen := false + efiLegacyBoot := false + bootDelayMilliseconds := int64(0) + bootRetry := false + bootRetryDelayMilliseconds := int64(10000) + enterSetupMode := false + coresPerSocket := int64(2) + memoryHotAddIncrementMiB := int64(256) + memoryHotAddLimitMiB := int64(16 * 1024) + return &InventoryVMHardware{ + GuestOS: guestOS, + InstantCloneFrozen: boolPointer(instantCloneFrozen), + Version: version, + UpgradePolicy: "NEVER", + UpgradeStatus: "NONE", + BootType: "EFI", + EFILegacyBoot: &efiLegacyBoot, + BootNetworkProtocol: "IPV4", + BootDelayMilliseconds: &bootDelayMilliseconds, + BootRetry: &bootRetry, + BootRetryDelayMilliseconds: &bootRetryDelayMilliseconds, + EnterSetupMode: &enterSetupMode, + BootDevices: []InventoryVMBootDevice{{ + Type: "DISK", + Disks: []string{"2000"}, + }}, + CPUCoresPerSocket: &coresPerSocket, + CPUHotAddEnabled: boolPointer(hotAddEnabled), + CPUHotRemoveEnabled: boolPointer(false), + MemoryHotAddEnabled: boolPointer(hotAddEnabled), + MemoryHotAddIncrementMiB: &memoryHotAddIncrementMiB, + MemoryHotAddLimitMiB: &memoryHotAddLimitMiB, + } +} + func boolPointer(value bool) *bool { return &value } diff --git a/internal/vmware/provider.go b/internal/vmware/provider.go index 6d26ae880..a9e695639 100644 --- a/internal/vmware/provider.go +++ b/internal/vmware/provider.go @@ -115,6 +115,40 @@ type InventoryVMTools struct { GuestRebootRequestTime string `json:"guest_reboot_request_time,omitempty"` } +// InventoryVMBootDevice preserves one vCenter VM boot-device entry as +// read-only virtual hardware context. +type InventoryVMBootDevice struct { + Type string `json:"type,omitempty"` + NIC string `json:"nic,omitempty"` + Disks []string `json:"disks,omitempty"` +} + +// InventoryVMHardware preserves vCenter VM hardware, CPU, memory, and boot +// configuration as read-only VM context. +type InventoryVMHardware struct { + GuestOS string `json:"guest_os,omitempty"` + InstantCloneFrozen *bool `json:"instant_clone_frozen,omitempty"` + Version string `json:"version,omitempty"` + UpgradePolicy string `json:"upgrade_policy,omitempty"` + UpgradeVersion string `json:"upgrade_version,omitempty"` + UpgradeStatus string `json:"upgrade_status,omitempty"` + UpgradeErrorMessage string `json:"upgrade_error_message,omitempty"` + BootType string `json:"boot_type,omitempty"` + EFILegacyBoot *bool `json:"efi_legacy_boot,omitempty"` + BootNetworkProtocol string `json:"boot_network_protocol,omitempty"` + BootDelayMilliseconds *int64 `json:"boot_delay_milliseconds,omitempty"` + BootRetry *bool `json:"boot_retry,omitempty"` + BootRetryDelayMilliseconds *int64 `json:"boot_retry_delay_milliseconds,omitempty"` + EnterSetupMode *bool `json:"enter_setup_mode,omitempty"` + BootDevices []InventoryVMBootDevice `json:"boot_devices,omitempty"` + CPUCoresPerSocket *int64 `json:"cpu_cores_per_socket,omitempty"` + CPUHotAddEnabled *bool `json:"cpu_hot_add_enabled,omitempty"` + CPUHotRemoveEnabled *bool `json:"cpu_hot_remove_enabled,omitempty"` + MemoryHotAddEnabled *bool `json:"memory_hot_add_enabled,omitempty"` + MemoryHotAddIncrementMiB *int64 `json:"memory_hot_add_increment_mib,omitempty"` + MemoryHotAddLimitMiB *int64 `json:"memory_hot_add_limit_mib,omitempty"` +} + // InventoryMetrics captures the current runtime metric floor projected onto // canonical Pulse metrics for VMware-backed hosts and VMs. type InventoryMetrics struct { @@ -201,6 +235,7 @@ type InventoryVM struct { NetworkAdapters []InventoryVMNetworkAdapter `json:"network_adapters,omitempty"` VirtualDisks []InventoryVMVirtualDisk `json:"virtual_disks,omitempty"` Tools *InventoryVMTools `json:"tools,omitempty"` + Hardware *InventoryVMHardware `json:"hardware,omitempty"` Metrics *InventoryMetrics `json:"metrics,omitempty"` } @@ -552,6 +587,7 @@ func vmwareRecordsFromSnapshot(snapshot *InventorySnapshot, now func() time.Time NetworkAdapters: vmwareNetworkAdaptersData(vm.NetworkAdapters), VirtualDisks: vmwareVirtualDisksData(vm.VirtualDisks), Tools: vmwareToolsData(vm.Tools), + Hardware: vmwareVMHardwareData(vm.Hardware), }, Tags: filterNonEmptyStrings( "vmware", @@ -714,6 +750,7 @@ func cloneInventoryVMs(in []InventoryVM) []InventoryVM { out[i].NetworkAdapters = cloneInventoryVMNetworkAdapters(in[i].NetworkAdapters) out[i].VirtualDisks = cloneInventoryVMVirtualDisks(in[i].VirtualDisks) out[i].Tools = cloneInventoryVMTools(in[i].Tools) + out[i].Hardware = cloneInventoryVMHardware(in[i].Hardware) out[i].Metrics = cloneInventoryMetrics(in[i].Metrics) } return out @@ -824,6 +861,39 @@ func cloneInventoryVMTools(in *InventoryVMTools) *InventoryVMTools { return &out } +func cloneInventoryVMHardware(in *InventoryVMHardware) *InventoryVMHardware { + if in == nil { + return nil + } + out := *in + out.InstantCloneFrozen = cloneBoolPointer(in.InstantCloneFrozen) + out.EFILegacyBoot = cloneBoolPointer(in.EFILegacyBoot) + out.BootDelayMilliseconds = cloneInt64Pointer(in.BootDelayMilliseconds) + out.BootRetry = cloneBoolPointer(in.BootRetry) + out.BootRetryDelayMilliseconds = cloneInt64Pointer(in.BootRetryDelayMilliseconds) + out.EnterSetupMode = cloneBoolPointer(in.EnterSetupMode) + out.BootDevices = cloneInventoryVMBootDevices(in.BootDevices) + out.CPUCoresPerSocket = cloneInt64Pointer(in.CPUCoresPerSocket) + out.CPUHotAddEnabled = cloneBoolPointer(in.CPUHotAddEnabled) + out.CPUHotRemoveEnabled = cloneBoolPointer(in.CPUHotRemoveEnabled) + out.MemoryHotAddEnabled = cloneBoolPointer(in.MemoryHotAddEnabled) + out.MemoryHotAddIncrementMiB = cloneInt64Pointer(in.MemoryHotAddIncrementMiB) + out.MemoryHotAddLimitMiB = cloneInt64Pointer(in.MemoryHotAddLimitMiB) + return &out +} + +func cloneInventoryVMBootDevices(in []InventoryVMBootDevice) []InventoryVMBootDevice { + if in == nil { + return nil + } + out := make([]InventoryVMBootDevice, len(in)) + for i := range in { + out[i] = in[i] + out[i].Disks = cloneStringSlice(in[i].Disks) + } + return out +} + func cloneInventoryEnrichmentIssues(in []InventoryEnrichmentIssue) []InventoryEnrichmentIssue { if in == nil { return nil @@ -1237,6 +1307,50 @@ func vmwareToolsData(tools *InventoryVMTools) *unifiedresources.VMwareToolsData } } +func vmwareVMHardwareData(hardware *InventoryVMHardware) *unifiedresources.VMwareVMHardwareData { + if hardware == nil { + return nil + } + return &unifiedresources.VMwareVMHardwareData{ + GuestOS: strings.TrimSpace(hardware.GuestOS), + InstantCloneFrozen: cloneBoolPointer(hardware.InstantCloneFrozen), + Version: strings.TrimSpace(hardware.Version), + UpgradePolicy: strings.TrimSpace(hardware.UpgradePolicy), + UpgradeVersion: strings.TrimSpace(hardware.UpgradeVersion), + UpgradeStatus: strings.TrimSpace(hardware.UpgradeStatus), + UpgradeErrorMessage: strings.TrimSpace(hardware.UpgradeErrorMessage), + BootType: strings.TrimSpace(hardware.BootType), + EFILegacyBoot: cloneBoolPointer(hardware.EFILegacyBoot), + BootNetworkProtocol: strings.TrimSpace(hardware.BootNetworkProtocol), + BootDelayMilliseconds: cloneInt64Pointer(hardware.BootDelayMilliseconds), + BootRetry: cloneBoolPointer(hardware.BootRetry), + BootRetryDelayMilliseconds: cloneInt64Pointer(hardware.BootRetryDelayMilliseconds), + EnterSetupMode: cloneBoolPointer(hardware.EnterSetupMode), + BootDevices: vmwareBootDevicesData(hardware.BootDevices), + CPUCoresPerSocket: cloneInt64Pointer(hardware.CPUCoresPerSocket), + CPUHotAddEnabled: cloneBoolPointer(hardware.CPUHotAddEnabled), + CPUHotRemoveEnabled: cloneBoolPointer(hardware.CPUHotRemoveEnabled), + MemoryHotAddEnabled: cloneBoolPointer(hardware.MemoryHotAddEnabled), + MemoryHotAddIncrementMiB: cloneInt64Pointer(hardware.MemoryHotAddIncrementMiB), + MemoryHotAddLimitMiB: cloneInt64Pointer(hardware.MemoryHotAddLimitMiB), + } +} + +func vmwareBootDevicesData(devices []InventoryVMBootDevice) []unifiedresources.VMwareBootDeviceData { + if len(devices) == 0 { + return nil + } + out := make([]unifiedresources.VMwareBootDeviceData, 0, len(devices)) + for _, device := range devices { + out = append(out, unifiedresources.VMwareBootDeviceData{ + Type: strings.TrimSpace(device.Type), + NIC: strings.TrimSpace(device.NIC), + Disks: cloneStringSlice(device.Disks), + }) + } + return out +} + func vmwareNetworkAdapterMACAddresses(adapters []InventoryVMNetworkAdapter) []string { if len(adapters) == 0 { return nil diff --git a/internal/vmware/provider_test.go b/internal/vmware/provider_test.go index 2278513af..6be4b040b 100644 --- a/internal/vmware/provider_test.go +++ b/internal/vmware/provider_test.go @@ -165,6 +165,31 @@ func TestProviderRecords_ProjectCanonicalVMwareResources(t *testing.T) { GuestRebootComponents: []string{"drivers"}, GuestRebootRequestTime: "2026-03-30T18:20:00Z", }, + Hardware: &InventoryVMHardware{ + GuestOS: "UBUNTU_64", + InstantCloneFrozen: boolPtr(false), + Version: "VMX_20", + UpgradePolicy: "AFTER_CLEAN_SHUTDOWN", + UpgradeVersion: "VMX_21", + UpgradeStatus: "PENDING", + BootType: "EFI", + EFILegacyBoot: boolPtr(false), + BootNetworkProtocol: "IPV4", + BootDelayMilliseconds: int64Ptr(5000), + BootRetry: boolPtr(true), + BootRetryDelayMilliseconds: int64Ptr(10000), + EnterSetupMode: boolPtr(false), + BootDevices: []InventoryVMBootDevice{ + {Type: "DISK", Disks: []string{"2000"}}, + {Type: "ETHERNET", NIC: "4000"}, + }, + CPUCoresPerSocket: int64Ptr(2), + CPUHotAddEnabled: boolPtr(true), + CPUHotRemoveEnabled: boolPtr(false), + MemoryHotAddEnabled: boolPtr(true), + MemoryHotAddIncrementMiB: int64Ptr(256), + MemoryHotAddLimitMiB: int64Ptr(16384), + }, }}, Datastores: []InventoryDatastore{{ Datastore: "datastore-11", @@ -295,6 +320,15 @@ func TestProviderRecords_ProjectCanonicalVMwareResources(t *testing.T) { if got := vmRecord.Resource.VMware.Tools.GuestRebootComponents; len(got) != 1 || got[0] != "drivers" { t.Fatalf("vm tools reboot components = %+v, want [drivers]", got) } + if got := vmRecord.Resource.VMware.Hardware; got == nil || got.Version != "VMX_20" || got.UpgradeStatus != "PENDING" { + t.Fatalf("vm hardware projection = %+v, want VMX_20 pending", got) + } + if got := vmRecord.Resource.VMware.Hardware.BootDevices; len(got) != 2 || got[0].Disks[0] != "2000" || got[1].NIC != "4000" { + t.Fatalf("vm hardware boot devices = %+v, want disk and ethernet entries", got) + } + if got := vmRecord.Resource.VMware.Hardware.MemoryHotAddLimitMiB; got == nil || *got != 16384 { + t.Fatalf("vm hardware memory hot-add limit = %+v, want 16384", got) + } if got := vmRecord.Resource.ParentName; got != "esxi-01.lab.local" { t.Fatalf("vm parent name = %q, want esxi-01.lab.local", got) }