Files
GraceSolutions 7669cb01ab Almost complete
2026-08-02 20:57:25 -04:00

512 lines
37 KiB
PowerShell

#region Get-HyperVInventory
Function Get-HyperVInventory
{
<#
.SYNOPSIS
Collects the compute inventory from one or more Hyper-V hosts and returns a normalized model that matches the Proxmox model.
.DESCRIPTION
Collection uses the Hyper-V module rather than hand written CIM queries so that Microsoft owns the translation of the
underlying virtualization classes. The module is required on the machine running this script; the hosts themselves are
reached through a CIM session, which every Hyper-V cmdlet accepts, so remote credentials and the DCOM fallback both work.
The following cmdlets are consumed.
Get-VMHost Host processor, memory, and path detail
Get-VMSwitch Virtual switches and their external adapter binding
Get-VM Virtual machine inventory including state, processor count, and memory
Get-VMNetworkAdapter Adapter names, MAC addresses, switch attachment, and guest reported addressing
Get-VMNetworkAdapterVlan Access VLAN identifiers per adapter
Get-VMHardDiskDrive, Get-VHD Allocated storage per virtual machine
Get-CimInstance Host chassis manufacturer, model, and serial number
Virtual switches become virtual switches within Rackpad and virtual machine network adapters become ports attached to those
virtual switches, which produces the same virtual topology shape that the Proxmox collector produces. The two collectors are
therefore interchangeable and can also run side by side.
.PARAMETER ComputerName
One or more Hyper-V host names or addresses.
.PARAMETER Credential
The credential used for the CIM session. Omit to use the credentials of the calling process.
.PARAMETER UseDCOM
Uses the DCOM protocol instead of WSMan. Useful for hosts where PowerShell remoting is unavailable.
.PARAMETER ClusterName
An optional cluster name recorded against every collected host.
.PARAMETER UseGuestIntrospection
Attempts to read the guest operating system caption over PowerShell Direct in addition to the key value pair exchange.
PowerShell Direct can only reach guests from the Hyper-V host itself, so this has no effect during a remote collection and
the key value pair exchange remains the source in that case. A guest credential is required.
.PARAMETER GuestCredential
The credential used inside the guest by PowerShell Direct. This is a guest credential, not a host credential.
.PARAMETER IncludeTemplates
Included for parity with the Proxmox collector. Hyper-V has no native template concept, so this parameter has no effect.
.EXAMPLE
$HyperVInventory = Get-HyperVInventory -ComputerName 'hypervisor01' -Credential $Credential
.NOTES
Author: Grace Solutions
Version: 2026.08.02.0000
Requires the Hyper-V module, which ships with the Hyper-V Management Tools feature.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'IncludeTemplates', Justification = 'Present for signature parity with the Proxmox collector so that both can be invoked identically.')]
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[String[]]$ComputerName,
[Parameter(Mandatory=$False)]
[AllowNull()]
[System.Management.Automation.PSCredential]$Credential,
[Parameter(Mandatory=$False)]
[Switch]$UseDCOM,
[Parameter(Mandatory=$False)]
[AllowEmptyString()]
[String]$ClusterName = '',
[Parameter(Mandatory=$False)]
[Switch]$UseGuestIntrospection,
[Parameter(Mandatory=$False)]
[AllowNull()]
[System.Management.Automation.PSCredential]$GuestCredential,
[Parameter(Mandatory=$False)]
[Switch]$IncludeTemplates
)
Try
{
#region The Hyper-V module performs the heavy lifting, so its absence is a hard failure rather than a degraded collection
$HyperVModule = Try {Get-Module -Name 'Hyper-V' -ListAvailable -ErrorAction SilentlyContinue | Sort-Object -Property @('Version') -Descending | Select-Object -First 1} Catch {$Null}
Switch ($Null -ieq $HyperVModule)
{
{($_ -eq $True)}
{
Throw 'The Hyper-V module could not be located. Install the Hyper-V Management Tools feature on the machine running this script.'
}
}
$Null = Import-Module -Name "$($HyperVModule.Path)" -Global -DisableNameChecking -Force -Verbose:$False
#endregion
$NodeObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$ComputerNameListCount = ($ComputerName | Measure-Object).Count
For ($ComputerNameIndex = 0; $ComputerNameIndex -lt $ComputerNameListCount; $ComputerNameIndex++)
{
$HyperVHostName = "$($ComputerName[$ComputerNameIndex])"
$WriteProgressParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$WriteProgressParameters.Activity = 'Collecting the Hyper-V inventory'
$WriteProgressParameters.Status = "Processing host `"$($HyperVHostName)`" ($($ComputerNameIndex + 1) of $($ComputerNameListCount))"
$WriteProgressParameters.PercentComplete = [System.Math]::Round((($ComputerNameIndex + 1) / $ComputerNameListCount) * 100, 2)
$WriteProgressParameters.Id = 22
$Null = Write-Progress @WriteProgressParameters
$CIMSession = $Null
#region Every Hyper-V and CIM call below is splatted with this, so a local collection can omit the session entirely
$SessionParameter = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
#endregion
Try
{
#region A local collection talks to the provider directly
# Requiring a CIM session for the local machine would mean requiring WinRM, which is frequently not
# enabled on a workstation that nonetheless has the Hyper-V role. A session is therefore only created
# when the target is genuinely remote or when an explicit credential has to be presented.
$LocalNameList = New-Object -TypeName 'System.Collections.Generic.List[String]'
$LocalNameList.Add('.')
$LocalNameList.Add('localhost')
$LocalNameList.Add('127.0.0.1')
$LocalNameList.Add("$($Env:ComputerName)")
$LocalNameList.Add("$([System.Net.Dns]::GetHostName())")
$TargetIsLocal = ("$($HyperVHostName)" -iin $LocalNameList) -and ($Null -ieq $Credential)
#endregion
#region Establish the CIM session, falling back to DCOM when WSMan is unavailable
Switch ($TargetIsLocal)
{
{($_ -eq $False)}
{
$ProtocolList = New-Object -TypeName 'System.Collections.Generic.List[String]'
Switch ($UseDCOM.IsPresent)
{
{($_ -eq $True)}
{
$ProtocolList.Add('DCOM')
}
{($_ -eq $False)}
{
$ProtocolList.Add('WSMan')
$ProtocolList.Add('DCOM')
}
}
:ProtocolLoop For ($ProtocolIndex = 0; $ProtocolIndex -lt $ProtocolList.Count; $ProtocolIndex++)
{
$NewCimSessionParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$NewCimSessionParameters.ComputerName = "$($HyperVHostName)"
$NewCimSessionParameters.SessionOption = New-CimSessionOption -Protocol "$($ProtocolList[$ProtocolIndex])"
$NewCimSessionParameters.ErrorAction = 'Stop'
Switch ($Null -ine $Credential)
{
{($_ -eq $True)}
{
$NewCimSessionParameters.Credential = $Credential
}
}
$CIMSession = Try {New-CimSession @NewCimSessionParameters} Catch {$Null}
Switch ($Null -ine $CIMSession)
{
{($_ -eq $True)}
{
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Connected to `"$($HyperVHostName)`" using the $($ProtocolList[$ProtocolIndex]) protocol."
Break ProtocolLoop
}
}
}
Switch ($Null -ieq $CIMSession)
{
{($_ -eq $True)}
{
Throw "A CIM session to `"$($HyperVHostName)`" could not be established using $($ProtocolList -Join ' or '). Enable PowerShell remoting on the host, or set UseDCOM."
}
}
$SessionParameter.CimSession = $CIMSession
}
{($_ -eq $True)}
{
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Collecting from the local machine directly, so no CIM session is required."
}
}
#endregion
#region Host detail
$VirtualMachineHost = Get-VMHost @SessionParameter
$HostComputerSystem = Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_ComputerSystem'
$HostOperatingSystem = Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_OperatingSystem'
$HostBios = Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_BIOS'
$HostProcessor = Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_Processor' | Select-Object -First 1
$HostStorageGB = 0
ForEach ($HostVolume In @(Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_LogicalDisk' -Filter 'DriveType = 3'))
{
$HostStorageGB = $HostStorageGB + [System.Math]::Round(([Double]"0$($HostVolume.Size)" / 1GB), 2)
}
$HostIPAddress = ''
ForEach ($HostNetworkConfiguration In @(Get-CimInstance @SessionParameter -Namespace 'root\CIMv2' -ClassName 'Win32_NetworkAdapterConfiguration' -Filter 'IPEnabled = True'))
{
ForEach ($HostAddress In @($HostNetworkConfiguration.IPAddress))
{
Switch (("$($HostAddress)" -imatch '^\d{1,3}(\.\d{1,3}){3}$') -and ([String]::IsNullOrWhiteSpace($HostIPAddress) -eq $True))
{
{($_ -eq $True)}
{
$HostIPAddress = "$($HostAddress)"
}
}
}
}
#endregion
#region Virtual switches
$BridgeObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
ForEach ($VirtualSwitch In @(Get-VMSwitch @SessionParameter -ErrorAction SilentlyContinue))
{
$UplinkPortList = New-Object -TypeName 'System.Collections.Generic.List[String]'
Switch ([String]::IsNullOrWhiteSpace("$($VirtualSwitch.NetAdapterInterfaceDescription)") -eq $False)
{
{($_ -eq $True)}
{
$UplinkPortList.Add("$($VirtualSwitch.NetAdapterInterfaceDescription)")
}
}
$BridgeProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$BridgeProperties.Name = "$($VirtualSwitch.Name)"
$BridgeProperties.Type = "$($VirtualSwitch.SwitchType)"
$BridgeProperties.UplinkPortList = $UplinkPortList.ToArray()
$BridgeProperties.CIDR = ''
$BridgeProperties.Gateway = ''
$BridgeProperties.IsVLANAware = $True
$BridgeProperties.Comment = "$($VirtualSwitch.Notes)"
$BridgeProperties.IsActive = $True
$BridgeProperties.SwitchKind = Switch ("$($VirtualSwitch.SwitchType)")
{
{($_ -ieq 'External')}
{
'external'
}
{($_ -ieq 'Private')}
{
'private'
}
Default
{
'internal'
}
}
$BridgeObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($BridgeProperties)))
}
#endregion
#region Virtual machines
$GuestObjectList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
#region The guest operating system is not a virtual machine property, so it is read from the key value pair exchange that integration services populates
$GuestOperatingSystemTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.String], [System.String]]'
ForEach ($KeyValuePairComponent In @(Get-CimInstance @SessionParameter -Namespace 'root\virtualization\v2' -ClassName 'Msvm_KvpExchangeComponent' -ErrorAction SilentlyContinue))
{
ForEach ($GuestExchangeItem In @($KeyValuePairComponent.GuestIntrinsicExchangeItems))
{
Switch ("$($GuestExchangeItem)" -imatch '(?s)<PROPERTY\s+NAME="Name".*?<VALUE>OSName</VALUE>.*?<PROPERTY\s+NAME="Data".*?<VALUE>(?<OperatingSystemName>[^<]+)</VALUE>')
{
{($_ -eq $True)}
{
$GuestOperatingSystemTable["$($KeyValuePairComponent.SystemName)"] = "$($Matches['OperatingSystemName'])".Trim()
}
}
}
}
#endregion
#region PowerShell Direct is only reachable from the host itself, so guest introspection is limited to a local collection
$GuestIntrospectionIsAvailable = ($UseGuestIntrospection.IsPresent -eq $True) -and ($Null -ine $GuestCredential) -and ("$($HyperVHostName)" -iin @('.', 'localhost', '127.0.0.1', "$($Env:ComputerName)", "$($VirtualMachineHost.Name)"))
Switch (($UseGuestIntrospection.IsPresent -eq $True) -and ($GuestIntrospectionIsAvailable -eq $False))
{
{($_ -eq $True)}
{
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Guest introspection was requested but is unavailable for `"$($HyperVHostName)`". [Reason: PowerShell Direct requires the collection to run on the Hyper-V host itself and requires a guest credential.] The key value pair exchange remains in use."
}
}
#endregion
ForEach ($VirtualMachine In @(Get-VM @SessionParameter -ErrorAction SilentlyContinue))
{
#region Guest operating system, preferring PowerShell Direct when it is reachable and falling back to the key value pair exchange
$GuestOperatingSystem = ''
Switch ($GuestOperatingSystemTable.ContainsKey("$($VirtualMachine.Id)"))
{
{($_ -eq $True)}
{
$GuestOperatingSystem = "$($GuestOperatingSystemTable["$($VirtualMachine.Id)"])"
}
}
Switch (($GuestIntrospectionIsAvailable -eq $True) -and ("$($VirtualMachine.State)" -ieq 'Running'))
{
{($_ -eq $True)}
{
$DirectResult = Try {Invoke-Command -VMId ($VirtualMachine.Id) -Credential ($GuestCredential) -ErrorAction Stop -ScriptBlock {"$((Get-CimInstance -ClassName 'Win32_OperatingSystem').Caption)"}} Catch {$Null}
Switch ([String]::IsNullOrWhiteSpace("$($DirectResult)") -eq $False)
{
{($_ -eq $True)}
{
$GuestOperatingSystem = "$($DirectResult)".Trim()
}
}
}
}
#endregion
#region Allocated storage
$GuestStorageGB = 0
ForEach ($VirtualHardDiskDrive In @(Get-VMHardDiskDrive -VM ($VirtualMachine) -ErrorAction SilentlyContinue))
{
$VirtualHardDisk = Try {Get-VHD @SessionParameter -Path "$($VirtualHardDiskDrive.Path)" -ErrorAction SilentlyContinue} Catch {$Null}
Switch ($Null -ine $VirtualHardDisk)
{
{($_ -eq $True)}
{
$GuestStorageGB = $GuestStorageGB + [System.Math]::Round(([Double]"0$($VirtualHardDisk.Size)" / 1GB), 2)
}
}
}
#endregion
#region Network adapters, their switch attachment, and their access VLAN
$GuestInterfaceList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
$GuestInterfacePosition = 1
ForEach ($NetworkAdapter In @(Get-VMNetworkAdapter -VM ($VirtualMachine) -ErrorAction SilentlyContinue))
{
$AdapterVlanSetting = Try {Get-VMNetworkAdapterVlan -VMNetworkAdapter ($NetworkAdapter) -ErrorAction SilentlyContinue} Catch {$Null}
$AccessVLANID = ''
Switch (($Null -ine $AdapterVlanSetting) -and ("$($AdapterVlanSetting.OperationMode)" -ieq 'Access') -and ([Int]"0$($AdapterVlanSetting.AccessVlanId)" -gt 0))
{
{($_ -eq $True)}
{
$AccessVLANID = "$($AdapterVlanSetting.AccessVlanId)"
}
}
$AdapterIPAddress = ''
ForEach ($CandidateAddress In @($NetworkAdapter.IPAddresses))
{
Switch (("$($CandidateAddress)" -imatch '^\d{1,3}(\.\d{1,3}){3}$') -and ("$($CandidateAddress)" -inotmatch '^(0\.|127\.|169\.254\.)') -and ([String]::IsNullOrWhiteSpace($AdapterIPAddress) -eq $True))
{
{($_ -eq $True)}
{
$AdapterIPAddress = "$($CandidateAddress)"
}
}
}
$InterfaceProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InterfaceProperties.Name = "$($NetworkAdapter.Name)"
$InterfaceProperties.ConfigurationKey = "net$($GuestInterfacePosition - 1)"
$InterfaceProperties.MACAddress = "$($NetworkAdapter.MacAddress)".ToUpperInvariant()
$InterfaceProperties.AdapterModel = Switch ($NetworkAdapter.IsLegacy) {{($_ -eq $True)} {'legacy'} Default {'synthetic'}}
$InterfaceProperties.BridgeName = "$($NetworkAdapter.SwitchName)"
$InterfaceProperties.VLANID = "$($AccessVLANID)"
$InterfaceProperties.IPAddress = "$($AdapterIPAddress)"
$InterfaceProperties.IsFirewalled = $False
$GuestInterfaceList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($InterfaceProperties)))
$GuestInterfacePosition++
}
#endregion
$GuestTagList = New-Object -TypeName 'System.Collections.Generic.List[String]'
$GuestProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$GuestProperties.VMID = "$($VirtualMachine.Id)"
$GuestProperties.Name = "$($VirtualMachine.Name)"
$GuestProperties.GuestType = 'vm'
$GuestProperties.Status = Switch ("$($VirtualMachine.State)")
{
{($_ -ieq 'Running')}
{
'online'
}
{($_ -iin @('Off', 'Saved'))}
{
'offline'
}
{($_ -iin @('Paused', 'Starting', 'Stopping', 'Saving'))}
{
'warning'
}
Default
{
'unknown'
}
}
$GuestProperties.IsTemplate = $False
$GuestProperties.CPUCoreCount = [Int]"0$($VirtualMachine.ProcessorCount)"
$GuestProperties.MemoryGB = [System.Math]::Round(([Double]"0$($VirtualMachine.MemoryStartup)" / 1GB), 2)
$GuestProperties.StorageGB = Switch ($GuestStorageGB -gt 0) {{($_ -eq $True)} {$GuestStorageGB} Default {$Null}}
$GuestProperties.OperatingSystemType = "Generation $($VirtualMachine.Generation)"
$GuestProperties.GuestOperatingSystem = "$($GuestOperatingSystem)"
$GuestProperties.Description = "$($VirtualMachine.Notes)".Trim()
$GuestProperties.TagList = $GuestTagList
$GuestProperties.InterfaceList = $GuestInterfaceList
$GuestProperties.NodeName = "$($VirtualMachineHost.Name)"
$GuestObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($GuestProperties)))
}
#endregion
$NodeProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$NodeProperties.Name = "$($VirtualMachineHost.Name)"
$NodeProperties.Status = 'online'
$NodeProperties.IPAddress = "$($HostIPAddress)"
$NodeProperties.CPUCoreCount = [Int]"0$($VirtualMachineHost.LogicalProcessorCount)"
$NodeProperties.MemoryGB = [System.Math]::Round(([Double]"0$($VirtualMachineHost.MemoryCapacity)" / 1GB), 2)
$NodeProperties.StorageGB = Switch ($HostStorageGB -gt 0) {{($_ -eq $True)} {$HostStorageGB} Default {$Null}}
$NodeProperties.Version = "$($HostOperatingSystem.Caption) $($HostOperatingSystem.Version)".Trim()
$NodeProperties.CPUModel = "$($HostProcessor.Name)".Trim()
$NodeProperties.Manufacturer = "$($HostComputerSystem.Manufacturer)"
$NodeProperties.Model = "$($HostComputerSystem.Model)"
$NodeProperties.SerialNumber = "$($HostBios.SerialNumber)"
$NodeProperties.BridgeList = $BridgeObjectList
$NodeProperties.GuestList = $GuestObjectList
$NodeObjectList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($NodeProperties)))
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Collected $($BridgeObjectList.Count) virtual switch(es) and $($GuestObjectList.Count) virtual machine(s) from Hyper-V host `"$($HyperVHostName)`"."
}
Catch
{
Write-Warning -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - The Hyper-V host `"$($HyperVHostName)`" could not be collected and will be skipped. Message: $($_.Exception.Message)"
}
Finally
{
Switch ($Null -ine $CIMSession)
{
{($_ -eq $True)}
{
$Null = Try {Remove-CimSession -CimSession ($CIMSession) -ErrorAction SilentlyContinue} Catch {$Null}
}
}
}
}
$Null = Write-Progress -Activity 'Collecting the Hyper-V inventory' -Id 22 -Completed
$InventoryProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InventoryProperties.SourceType = 'HyperV'
$InventoryProperties.BaseURL = $Null
$InventoryProperties.ClusterName = "$($ClusterName)"
$InventoryProperties.RetrievedAt = [System.DateTime]::UtcNow
$InventoryProperties.NodeList = $NodeObjectList
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($InventoryProperties))
}
Catch
{
Throw
}
}
#endregion