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) }