modules/HyperV/HyperV.psm1

<#
    HyperV.psm1 - Hyper-V host, switches, VMs, disks, replication (read-only).
    Role-gated. NEVER changes VM state or creates checkpoints.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='HyperV'; DisplayName='Hyper-V'; Category='Virtualization'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$true; RequiresDomainContext=$false
        RequiresRole='Hyper-V'; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('HyperVHost','HyperVVirtualSwitches','HyperVVMs','HyperVDisks','HyperVReplication')
        ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$false
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    $present = (Get-CommandAvailable -Name 'Get-VM') -or (Get-Service -Name 'vmms' -ErrorAction SilentlyContinue)
    if ($present) { return [pscustomobject]@{ ModuleName='HyperV'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } }
    [pscustomobject]@{ ModuleName='HyperV'; CanRun=$false; Status='NotApplicable'; Reason='Hyper-V (Get-VM / vmms) not present.'; Limitations=@() }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    # Default cmdlets resolve $env:COMPUTERNAME through DNS on every call (~1 s each; 20-30x slower than 'localhost'), and a resolution failure is swallowed by -ErrorAction SilentlyContinue as an empty result.
    $PSDefaultParameterValues = @{ 'Get-VM*:ComputerName' = 'localhost'; 'Get-VHD:ComputerName' = 'localhost' }
    $host_=[System.Collections.Generic.List[object]]::new(); $sw=[System.Collections.Generic.List[object]]::new()
    $vms=[System.Collections.Generic.List[object]]::new(); $disks=[System.Collections.Generic.List[object]]::new(); $repl=[System.Collections.Generic.List[object]]::new()
    if (-not (Get-CommandAvailable -Name 'Get-VM')) { Add-Limitation -Context $Context -Module 'HyperV' -Message 'Hyper-V PowerShell module not available; cannot enumerate VMs.' | Out-Null; return ,@{ HyperVHost=@(); HyperVVirtualSwitches=@(); HyperVVMs=@(); HyperVDisks=@(); HyperVReplication=@() } }
    try { $h = Get-VMHost -ErrorAction SilentlyContinue; if ($h) { $host_.Add([pscustomobject]@{ ComputerName=$h.Name; VirtualHardDiskPath=$h.VirtualHardDiskPath; VirtualMachinePath=$h.VirtualMachinePath; LogicalProcessorCount=$h.LogicalProcessorCount; MemoryGB=(Convert-BytesToGB $h.MemoryCapacity) }) } } catch { }
    try { foreach ($s in (Get-VMSwitch -ErrorAction SilentlyContinue)) { $sw.Add([pscustomobject]@{ Name=$s.Name; SwitchType=[string]$s.SwitchType; NetAdapter=$s.NetAdapterInterfaceDescription }) } } catch { }
    # The host's default VM config version: a VM below it was built on older Hyper-V and
    # needs a version upgrade (one-way) once moved to a newer host.
    $hostDefaultVer = $null
    try { $hostDefaultVer = [version](Get-VMHostSupportedVersion -Default -ErrorAction Stop).Version } catch { }
    try {
        foreach ($v in (Get-VM -ErrorAction SilentlyContinue)) {
            $snapCount = 0
            try { $snapCount = @(Get-VMSnapshot -VMName $v.Name -ErrorAction SilentlyContinue).Count } catch { }
            $iso = $false
            try { $iso = [bool](@(Get-VMDvdDrive -VMName $v.Name -ErrorAction SilentlyContinue | Where-Object { $_.Path }).Count -gt 0) } catch { }
            $switches = ''
            try { $switches = (@(Get-VMNetworkAdapter -VMName $v.Name -ErrorAction SilentlyContinue | ForEach-Object { if ($_.SwitchName) { $_.SwitchName } else { '(not connected)' } }) -join '; ') } catch { }
            # (MemoryMinimum/Maximum are 512 MB / 1 TB placeholders unless dynamic memory is on, so they are only reported then.)
            $behind = $false
            try { if ($hostDefaultVer -and ([version]$v.Version -lt $hostDefaultVer)) { $behind = $true } } catch { }
            $vms.Add([pscustomobject]@{ Name=$v.Name; State=[string]$v.State; Generation=$v.Generation; ProcessorCount=$v.ProcessorCount; MemoryAssignedGB=(Convert-BytesToGB $v.MemoryAssigned); MemoryMinimumGB=$(if ($v.DynamicMemoryEnabled) { Convert-BytesToGB $v.MemoryMinimum } else { $null }); MemoryMaximumGB=$(if ($v.DynamicMemoryEnabled) { Convert-BytesToGB $v.MemoryMaximum } else { $null }); DynamicMemoryEnabled=$v.DynamicMemoryEnabled; Version=$v.Version; VersionBehindHostDefault=$behind; HasCheckpoints=([bool]($snapCount -gt 0)); CheckpointCount=$snapCount; AutomaticStartAction=[string]$v.AutomaticStartAction; IsoMounted=$iso; SwitchNames=$switches; VMPath=$v.Path; Notes=$v.Notes })
            try {
                foreach ($d in (Get-VMHardDiskDrive -VMName $v.Name -ErrorAction SilentlyContinue)) {
                    # Size/type/parent come from the VHD itself; a locked or missing file must not lose the row.
                    $vhd = $null; try { if ($d.Path) { $vhd = Get-VHD -Path $d.Path -ErrorAction Stop } } catch { }
                    $parent = if ($vhd) { [string]$vhd.ParentPath } else { '' }
                    $disks.Add([pscustomobject]@{
                        VMName=$v.Name; Path=$d.Path; ControllerType=[string]$d.ControllerType; PassThrough=([bool]($null -ne $d.DiskNumber))
                        VhdType=$(if ($vhd) { [string]$vhd.VhdType } else { '' }); MaxSizeGB=$(if ($vhd) { Convert-BytesToGB $vhd.Size } else { $null }); FileSizeGB=$(if ($vhd) { Convert-BytesToGB $vhd.FileSize } else { $null })
                        ParentPath=$parent; IsDifferencing=([bool]($parent -or ([string]$d.Path -match '(?i)\.avhdx?$')))   # .avhd(x) = checkpoint child even if Get-VHD failed
                    })
                }
            } catch { }
            try { $r = Get-VMReplication -VMName $v.Name -ErrorAction SilentlyContinue; if ($r) { $repl.Add([pscustomobject]@{ VMName=$v.Name; State=[string]$r.State; Health=[string]$r.Health; Mode=[string]$r.ReplicationMode }) } } catch { }
        }
    } catch { Add-Limitation -Context $Context -Module 'HyperV' -Message 'VM enumeration failed.' -Reason $_.Exception.Message | Out-Null }
    return ,@{ HyperVHost=@($host_); HyperVVirtualSwitches=@($sw); HyperVVMs=@($vms); HyperVDisks=@($disks); HyperVReplication=@($repl) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'HyperVHost'            -Description 'Hyper-V host settings.'    -Rows (& $get 'HyperVHost')            -Visibility 'Internal' -SourceModule 'HyperV' | Out-Null
    Add-DataSet -Context $Context -Name 'HyperVVirtualSwitches' -Description 'Virtual switches.'         -Rows (& $get 'HyperVVirtualSwitches') -Visibility 'Internal' -SourceModule 'HyperV' | Out-Null
    Add-DataSet -Context $Context -Name 'HyperVVMs'             -Description 'Virtual machine inventory.'-Rows (& $get 'HyperVVMs')             -Visibility 'Both'     -SourceModule 'HyperV' | Out-Null
    Add-DataSet -Context $Context -Name 'HyperVDisks'           -Description 'VM virtual disks.'         -Rows (& $get 'HyperVDisks')           -Visibility 'Internal' -SourceModule 'HyperV' | Out-Null
    Add-DataSet -Context $Context -Name 'HyperVReplication'     -Description 'VM replication status.'    -Rows (& $get 'HyperVReplication')     -Visibility 'Internal' -SourceModule 'HyperV' | Out-Null
}

function Get-DiscoveryFollowUpQuestions {
    param([object]$Context)
    try { if ($Context.DataSets.Contains('HyperVVMs') -and @($Context.DataSets['HyperVVMs'].Rows).Count -gt 0) { Add-FollowUpQuestion -Context $Context -Category 'Critical systems' -Module 'HyperV' -Audience 'Both' -Question 'Is every VM on this Hyper-V host inventoried, backed up, and accounted for in the migration/refresh plan?' | Out-Null } } catch { }
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions'