Private/Get-HyperVMetadata.ps1

function Get-HyperVMetadata {
    <#
    .SYNOPSIS
        Collects all relevant metadata from a Hyper-V VM into a normalized object.
    .DESCRIPTION
        Pulls VM properties including OS information, hardware configuration,
        integration services status, network adapters, virtual hard disks, checkpoint
        (snapshot) information, creation date, power state, and notes. Returns a
        normalized PSCustomObject with the same structure as Get-VMMetadata (VMware),
        so the rest of the module (profiles, tag resolution, reports) works unchanged.
 
        This function expects a VM object from the Hyper-V Get-VM cmdlet.
    .PARAMETER VM
        A Hyper-V VM object from Get-VM.
    .PARAMETER IncludeCreationDate
        If specified, includes the VM creation date from the VM's CreationTime property.
    .EXAMPLE
        $vm = Hyper-V\Get-VM -Name "WebServer01"
        $meta = Get-HyperVMetadata -VM $vm
        $meta.NumCPU
    .EXAMPLE
        Hyper-V\Get-VM | ForEach-Object { Get-HyperVMetadata -VM $_ -IncludeCreationDate }
    .NOTES
        Author: Larry Roberts
        Part of the VM-AutoTagger module.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]$VM,
        [switch]$IncludeCreationDate
    )
    process {
        $vmName = $VM.Name
        Write-Verbose "Collecting Hyper-V metadata for VM: $vmName"

        $powerState = switch ($VM.State) {
            'Running'    { 'PoweredOn' }
            'Off'        { 'PoweredOff' }
            'Saved'      { 'Suspended' }
            'Paused'     { 'Suspended' }
            default      { 'PoweredOff' }
        }

        $numCPU = [int]$VM.ProcessorCount
        $memoryGB = [double]($VM.MemoryAssigned / 1GB)
        if ($memoryGB -eq 0 -and $VM.MemoryStartup) {
            $memoryGB = [double]($VM.MemoryStartup / 1GB)
        }
        $notes = if ($VM.Notes) { $VM.Notes } else { '' }

        # Guest OS info from integration services
        $guestFamily = ''
        $guestFullName = ''
        $guestId = ''

        # Try to get guest OS from WMI/CIM on the VM host
        try {
            $kvpItems = $VM | Get-VMIntegrationService | Where-Object { $_.Name -eq 'Key-Value Pair Exchange' }
            if ($kvpItems) {
                # Check OS data from KVP
                $guestOS = ($VM | Select-Object -ExpandProperty NetworkAdapters -ErrorAction SilentlyContinue | Select-Object -First 1)
            }
        } catch { }

        # Fall back to VM's GuestOperatingSystem property (from Hyper-V integration services)
        if ([string]::IsNullOrWhiteSpace($guestFullName)) {
            try {
                $vmInfo = Get-CimInstance -Namespace 'root\virtualization\v2' -ClassName 'Msvm_ComputerSystem' -Filter "ElementName='$vmName'" -ErrorAction SilentlyContinue
                if ($vmInfo) {
                    $summary = $vmInfo | Invoke-CimMethod -MethodName 'GetSummaryInformation' -Arguments @{RequestedInformation=@(4)} -ErrorAction SilentlyContinue
                    if ($summary -and $summary.SummaryInformation) {
                        $guestFullName = $summary.SummaryInformation.GuestOperatingSystem
                    }
                }
            } catch { }
        }

        # Simpler fallback: VM.Generation and OS name hints
        if ([string]::IsNullOrWhiteSpace($guestFullName)) {
            $guestFullName = if ($VM.PSObject.Properties['GuestOperatingSystem']) { $VM.GuestOperatingSystem } else { '' }
        }

        # Derive family from guest full name
        if (-not [string]::IsNullOrWhiteSpace($guestFullName)) {
            if ($guestFullName -match 'Windows') { $guestFamily = 'windowsGuest' }
            elseif ($guestFullName -match 'Linux|Ubuntu|CentOS|RHEL|Debian|SLES|Fedora') { $guestFamily = 'linuxGuest' }
            elseif ($guestFullName -match 'FreeBSD') { $guestFamily = 'freebsdGuest' }
        }

        # VMware Tools equivalent = Integration Services
        $toolsStatus = 'toolsNotInstalled'
        $toolsVersion = ''
        try {
            $icStatus = $VM | Get-VMIntegrationService -ErrorAction SilentlyContinue
            if ($icStatus) {
                $heartbeat = $icStatus | Where-Object { $_.Name -eq 'Heartbeat' }
                if ($heartbeat -and $heartbeat.Enabled) {
                    $toolsStatus = if ($heartbeat.PrimaryStatusDescription -eq 'OK') { 'toolsOk' } else { 'toolsOld' }
                }
                $toolsVersion = 'IntegrationServices'
            }
        } catch { }

        # IP addresses from network adapters
        $ipAddresses = @()
        try {
            $netAdapters = $VM | Get-VMNetworkAdapter -ErrorAction SilentlyContinue
            if ($netAdapters) {
                foreach ($na in $netAdapters) {
                    if ($na.IPAddresses) {
                        $ipAddresses += @($na.IPAddresses | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' })
                    }
                }
            }
        } catch { }

        # Networks (virtual switch names)
        $networks = @()
        try {
            $netAdapters = $VM | Get-VMNetworkAdapter -ErrorAction SilentlyContinue
            if ($netAdapters) {
                $networks = @($netAdapters | ForEach-Object {
                    if ($_.SwitchName) { $_.SwitchName } else { 'Disconnected' }
                })
            }
        } catch { }

        # Storage (VHD paths -> derive "datastores" as drive letters or CSV paths)
        $datastores = @()
        $provisionedSpaceGB = 0
        $usedSpaceGB = 0
        try {
            $vhds = $VM | Get-VMHardDiskDrive -ErrorAction SilentlyContinue
            foreach ($vhd in $vhds) {
                if ($vhd.Path) {
                    $driveLetter = [System.IO.Path]::GetPathRoot($vhd.Path)
                    if ($driveLetter -and $datastores -notcontains $driveLetter) {
                        $datastores += $driveLetter
                    }
                    # Get VHD size info
                    try {
                        $vhdInfo = Get-VHD -Path $vhd.Path -ErrorAction SilentlyContinue
                        if ($vhdInfo) {
                            $provisionedSpaceGB += [math]::Round($vhdInfo.Size / 1GB, 2)
                            $usedSpaceGB += [math]::Round($vhdInfo.FileSize / 1GB, 2)
                        }
                    } catch { }
                }
            }
        } catch { }

        # Snapshots (called "checkpoints" in Hyper-V)
        $snapshotCount = 0
        $oldestSnapshotAge = $null
        $snapshotDetails = @()
        try {
            $checkpoints = $VM | Get-VMSnapshot -ErrorAction SilentlyContinue
            if ($checkpoints) {
                $snapshotCount = @($checkpoints).Count
                $snapshotDetails = @($checkpoints | ForEach-Object {
                    [PSCustomObject]@{
                        Name    = $_.Name
                        Created = $_.CreationTime
                        SizeGB  = 0  # Hyper-V doesn't expose snapshot size directly
                        AgeDays = [math]::Round(((Get-Date) - $_.CreationTime).TotalDays, 1)
                    }
                })
                $oldest = $checkpoints | Sort-Object CreationTime | Select-Object -First 1
                if ($oldest) {
                    $oldestSnapshotAge = [math]::Round(((Get-Date) - $oldest.CreationTime).TotalDays, 1)
                }
            }
        } catch { }

        # Creation date
        $createdDate = $null
        if ($IncludeCreationDate) {
            if ($VM.CreationTime -and $VM.CreationTime -ne [datetime]::MinValue) {
                $createdDate = $VM.CreationTime
            }
        }

        # Last powered on (Uptime)
        $lastPoweredOn = $null
        if ($VM.Uptime -and $VM.State -eq 'Running') {
            $lastPoweredOn = (Get-Date) - $VM.Uptime
        }

        # Host info
        $hostName = if ($VM.ComputerName) { $VM.ComputerName } else { $env:COMPUTERNAME }

        # Snapshot risk
        $snapshotRisk = 'Clean'
        if ($snapshotCount -gt 0) {
            if ($null -ne $oldestSnapshotAge -and $oldestSnapshotAge -gt 7) {
                $snapshotRisk = 'Stale Snapshots'
            } else {
                $snapshotRisk = 'Has Snapshots'
            }
        }

        # VM Age
        $vmAgeCategory = 'Unknown'
        if ($null -ne $createdDate) {
            $ageDays = ((Get-Date) - $createdDate).TotalDays
            if ($ageDays -lt 30) { $vmAgeCategory = 'Less than 30d' }
            elseif ($ageDays -lt 90) { $vmAgeCategory = '30-90d' }
            elseif ($ageDays -lt 365) { $vmAgeCategory = '90d-1yr' }
            else { $vmAgeCategory = 'Over 1yr' }
        }

        $metadata = [PSCustomObject]@{
            Name               = $vmName
            PowerState         = $powerState
            GuestFamily        = $guestFamily
            GuestFullName      = $guestFullName
            GuestId            = $guestId
            NumCPU             = $numCPU
            MemoryGB           = [math]::Round($memoryGB, 2)
            ToolsStatus        = $toolsStatus
            ToolsVersion       = $toolsVersion
            IPAddresses        = $ipAddresses
            Networks           = $networks
            Datastores         = $datastores
            SnapshotCount      = $snapshotCount
            SnapshotDetails    = $snapshotDetails
            OldestSnapshotAge  = $oldestSnapshotAge
            SnapshotRisk       = $snapshotRisk
            CreatedDate        = $createdDate
            VMAge              = $vmAgeCategory
            LastPoweredOn      = $lastPoweredOn
            Notes              = $notes
            CustomAttributes   = @{}
            ProvisionedSpaceGB = $provisionedSpaceGB
            UsedSpaceGB        = $usedSpaceGB
            Host               = $hostName
            Cluster            = ''
            Datacenter         = ''
            Folder             = ''
            VMId               = if ($VM.Id) { [string]$VM.Id } else { '' }
        }
        $metadata.PSObject.TypeNames.Insert(0, 'VMAutoTagger.VMMetadata')
        return $metadata
    }
}