manifests/collectors/Compute/VirtualMachine.psd1

#
# GENERATED by scripts/ConvertTo-ScoutCollectorDefinition.ps1 from Modules/Public/InventoryModules/Compute/VirtualMachine.ps1 (AB#5660).
# Field expressions are copied verbatim from the original collector and evaluate in an
# equivalent scope -- see docs/design/decisions/declarative-collectors.md.
# Review before trusting; regenerate rather than hand-patch if the source collector changes.
#
@{
    ResourceTypes = @(
        'microsoft.compute/virtualmachines'
    )

    ResourceTypeMatching = 'Grouped'

    AdditionalFilter = $null

    FilterPreamble = ''

    RowLoopVariable = '1'

    SetupPreamble = @'
$vm = $Resources | Where-Object {$_.TYPE -eq 'microsoft.compute/virtualmachines'}
        $nic = $Resources | Where-Object {$_.TYPE -eq 'microsoft.network/networkinterfaces'}
        $vmexp = $Resources | Where-Object {$_.TYPE -eq 'microsoft.compute/virtualmachines/extensions'}
        $disk = $Resources | Where-Object {$_.TYPE -eq 'microsoft.compute/disks'}
        $VirtualNetwork = $Resources | Where-Object { $_.TYPE -eq 'microsoft.network/virtualnetworks' }
        $VMExtraDetails = $Resources | Where-Object { $_.TYPE -eq 'AZSC/VM/SKU' }
        $VMQuotas = $Resources | Where-Object { $_.TYPE -eq 'AZSC/VM/Quotas' }
'@


    SetupVariables = @(
        'vm'
        'nic'
        'vmexp'
        'disk'
        'VirtualNetwork'
        'VMExtraDetails'
        'VMQuotas'
    )

    Preamble = @'
$ResUCount = 1
                    # $SUB does not always contain the resource's subscription -- a management-group
                    # scoped run, a subscription the caller lost access to mid-run, or simply a
                    # resource row from a subscription outside the requested scope all leave this
                    # Where-Object matching nothing. An EMPTY result is not $null, and reading
                    # .Name off it throws under StrictMode (AB#5667), so the name is resolved once,
                    # defensively, instead of at the point of use ~400 lines below.
                    $sub1 = $SUB | Where-Object { $_.id -eq $1.subscriptionId }
                    # The else arm is $null, NOT '': with StrictMode off $sub1.Name on an unmatched ($null)
                    # $sub1 evaluated to $null, and the ~110 collectors that still read $sub1.Name directly
                    # emit $null here. '' was a silent behaviour change -- the declarative equivalence proof
                    # caught it on 11 collectors, and it would have been invisible on the rest (AB#5659).
                    $SubscriptionName = if ($sub1) { @($sub1)[0].Name } else { $null }
                    $data = $1.PROPERTIES
                    # $data.timeCreated: absent entirely on some API versions/resource kinds, not
                    # just $null -- a plain member read throws under StrictMode when the
                    # NoteProperty genuinely does not exist (AB#5667). PSObject.Properties[...]
                    # is a safe existence check either way.
                    $timecreated = if ($data.PSObject.Properties['timeCreated']) { $data.timeCreated } else { $null }
                    $timecreated = if ($timecreated) { ([datetime]$timecreated).ToString("yyyy-MM-dd HH:mm") } else { '' }
                    $dataSize = ''
                    $StorAcc = ''
                    # `extended` is populated by the instance-view expansion and is ABSENT (not
                    # null) on a plain Resource Graph row, and `storageProfile.imageReference` is
                    # absent on a VM created from a specialised/attached disk rather than a
                    # gallery image -- both chains throw under StrictMode (AB#5667). The
                    # fall-back logic is unchanged; only the reads are made safe.
                    $ExtendedOsName = Get-AZSCSafeProperty -InputObject $data -Path 'extended.instanceView.osname'
                    $ExtendedOsVersion = Get-AZSCSafeProperty -InputObject $data -Path 'extended.instanceView.osversion'
                    $ImageOffer = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.imageReference.offer'
                    $ImageSku = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.imageReference.sku'
 
                    $OSName = if(![string]::IsNullOrEmpty($ExtendedOsName)){$ExtendedOsName}else{$ImageOffer}
                    $OSVersion = if(![string]::IsNullOrEmpty($ExtendedOsVersion)){$ExtendedOsVersion}else{$ImageSku}
 
                    # Extra VM Details
                    #
                    # $VMExtraDetails ($Resources filtered to the synthetic 'AZSC/VM/SKU' type) is
                    # empty on most tenants/fixtures -- member access on an empty collection
                    # throws under StrictMode (AB#5667), so every step of this chain, and every
                    # capability variable it feeds, is reset to a known value up front rather than
                    # left holding a previous VM's value when this VM has no match.
                    $vCPUs = $null; $vCPUsPerCore = $null; $RAM = $null; $MaxDataDiskCount = $null
                    $UncachedDiskIOPS = $null; $UncachedDiskBytesPerSecond = $null; $MaxNetworkInterfaces = $null
 
                    $VMExtraDetail = if ($VMExtraDetails) { $VMExtraDetails.properties | Where-Object {(Get-AZSCSafeProperty -InputObject $_ -Path 'Location') -eq $1.location} } else { $null }
                    # hardwareProfile is absent on some VM shapes (and on any malformed row);
                    # resolved once here so the Where-Object below does not read it per SKU.
                    $VMSize = Get-AZSCSafeProperty -InputObject $data -Path 'hardwareProfile.vmSize'
                    $VMExtraDetail = if ($VMExtraDetail) { $VMExtraDetail.SKUs | Where-Object {$_.Name -eq $VMSize} } else { $null }
 
                    if ($VMExtraDetail) {
                        foreach ($Capability in $VMExtraDetail.Capabilities) {
                            if ($Capability.Name -eq 'vCPUs') {$vCPUs = $Capability.Value}
                            if ($Capability.Name -eq 'vCPUsPerCore') {$vCPUsPerCore = $Capability.Value}
                            if ($Capability.Name -eq 'MemoryGB') {$RAM = $Capability.Value}
                            if ($Capability.Name -eq 'MaxDataDiskCount') {$MaxDataDiskCount = $Capability.Value}
                            if ($Capability.Name -eq 'UncachedDiskIOPS') {$UncachedDiskIOPS = $Capability.Value}
                            if ($Capability.Name -eq 'UncachedDiskBytesPerSecond') {$UncachedDiskBytesPerSecond = ([math]::Round($Capability.Value / 1024) / 1024)}
                            if ($Capability.Name -eq 'MaxNetworkInterfaces') {$MaxNetworkInterfaces = $Capability.Value}
                        }
                    }
 
                    # Quotas -- $VMExtraDetail/$VMQuotas/$Quota are all empty on most
                    # tenants/fixtures (the synthetic 'AZSC/VM/SKU' and 'AZSC/VM/Quotas' types are
                    # rarely present), and member access on an empty collection throws under
                    # StrictMode (AB#5667), so every step is guarded rather than chained.
                    $Size = if ($VMExtraDetail) { $VMExtraDetail.Family } else { $null }
                    $Quota = if ($VMQuotas) { $VMQuotas.properties | Where-Object {(Get-AZSCSafeProperty -InputObject $_ -Path 'SubId') -eq $1.subscriptionId} } else { $null }
                    $Quota = if ($Quota) { $Quota | Where-Object {(Get-AZSCSafeProperty -InputObject $_ -Path 'Location') -eq $1.location} } else { $null }
                    $RemainingQuota = if ($Quota) {
                        (($Quota.Data | Where-Object {$_.Name.Value -eq $Size}).Limit - ($Quota.Data | Where-Object {$_.Name.Value -eq $Size}).CurrentValue)
                    } else { $null }
 
                    # Same "$VMExtraDetail may be empty" guard as above, for the two remaining
                    # chains off it that are only read once per VM (further down, in $obj).
                    $VMZonesAvailable = if ($VMExtraDetail) { [string]$VMExtraDetail.LocationInfo.ZoneDetails.Name } else { '' }
                    $VMCapabilities = if ($VMExtraDetail) { [string]$VMExtraDetail.LocationInfo.ZoneDetails.Capabilities.Name } else { '' }
 
                    $Retired = Foreach ($Retirement in $Retirements)
                        {
                            if ($Retirement.id -eq $1.id) { $Retirement }
                        }
                    if ($Retired)
                        {
                            $RetiredFeature = foreach ($Retire in $Retired)
                                {
                                    $RetiredServiceID = $Unsupported | Where-Object {$_.Id -eq $Retired.ServiceID}
                                    $tmp0 = [PSCustomObject]@{
                                            'RetiredFeature' = $RetiredServiceID.RetiringFeature
                                            'RetiredDate' = $RetiredServiceID.RetirementDate
                                        }
                                    $tmp0
                                }
                            $RetiringFeature = if (@($RetiredFeature.RetiredFeature).count -gt 1) { $RetiredFeature.RetiredFeature | ForEach-Object { $_ + ' ,' } }else { $RetiredFeature.RetiredFeature}
                            $RetiringFeature = [string]$RetiringFeature
                            $RetiringFeature = if ($RetiringFeature -like '* ,*') { $RetiringFeature -replace ".$" }else { $RetiringFeature }
 
                            $RetiringDate = if (@($RetiredFeature.RetiredDate).count -gt 1) { $RetiredFeature.RetiredDate | ForEach-Object { $_ + ' ,' } }else { $RetiredFeature.RetiredDate}
                            $RetiringDate = [string]$RetiringDate
                            $RetiringDate = if ($RetiringDate -like '* ,*') { $RetiringDate -replace ".$" }else { $RetiringDate }
                        }
                    else
                        {
                            $RetiringFeature = $null
                            $RetiringDate = $null
                        }
 
                    #Extensions
                    $ext = @()
                    $AzDiag = ''
                    $Azinsights = ''
                    # licenseType is only present at all when Hybrid Benefit is configured -- most
                    # VMs never set it, so a plain $data.licenseType read throws under StrictMode
                    # on the common case, not the exception (AB#5667).
                    $LicenseType = if ($data.PSObject.Properties['licenseType']) { $data.licenseType } else { $null }
                    $Lic = switch ($LicenseType) {
                        'Windows_Server' { 'Azure Hybrid Benefit for Windows' }
                        'Windows_Client' { 'Windows client with multi-tenant hosting' }
                        'RHEL_BYOS' { 'Azure Hybrid Benefit for Redhat' }
                        'SLES_BYOS' { 'Azure Hybrid Benefit for SUSE' }
                        default { $LicenseType }
                    }
                    $Lic = if($Lic){$Lic}else{'None'}
                    $ext = foreach ($vmextension in $vmexp)
                        {
                            if (($vmextension.id -split "/")[8] -eq $1.name) { $vmextension.properties.Publisher }
                        }
                    if ($null -ne $ext)
                        {
                            $ext = foreach ($ex in $ext)
                                {
                                    if ($ex | Where-Object { $_ -eq 'Microsoft.Azure.Performance.Diagnostics' }) { $AzDiag = $true }
                                    if ($ex | Where-Object { $_ -eq 'Microsoft.EnterpriseCloud.Monitoring' }) { $Azinsights = $true }
                                    $ex + ', '
                                }
                            $ext = [string]$ext
                            $ext = $ext.Substring(0, $ext.Length - 2)
                        }
 
                    # Every $data.<chain> below is optional-block territory: a VM that does not
                    # use it does not merely carry a $null value, the whole block is ABSENT from
                    # the API response, and a raw chain throws under StrictMode the moment it
                    # hits the missing segment (AB#5667). Get-AZSCSafeProperty returns $null
                    # instead, exactly like the (unreachable-in-practice) $null a pre-StrictMode
                    # reader of this same chain would have seen.
                    # Neither branch below runs when a VM carries neither a windowsConfiguration
                    # nor a linuxConfiguration block, and reading an unassigned variable is its
                    # own StrictMode failure -- distinct from the missing-property class, and
                    # worse, because without StrictMode it silently reuses the PREVIOUS VM's
                    # value for every VM after the first (this loop reuses one scope).
                    $Autoupdate = $null
                    $WindowsAutoUpdate = Get-AZSCSafeProperty -InputObject $data -Path 'osProfile.windowsConfiguration.enableAutomaticUpdates'
                    $LinuxPatchMode = Get-AZSCSafeProperty -InputObject $data -Path 'osProfile.linuxConfiguration.patchSettings.patchMode'
 
                    if(![string]::IsNullOrEmpty($WindowsAutoUpdate))
                        {
                            if($WindowsAutoUpdate -eq 'True')
                                {
                                    $Autoupdate = $true
                                }
                            else
                                {
                                    $Autoupdate = $false
                                }
                        }
                    elseif(![string]::IsNullOrEmpty($LinuxPatchMode))
                        {
                            if($LinuxPatchMode -eq 'automaticbyos')
                                {
                                    $Autoupdate = $true
                                }
                            else
                                {
                                    $Autoupdate = $false
                                }
                        }
 
                    $AvailabilitySetValue = Get-AZSCSafeProperty -InputObject $data -Path 'availabilitySet'
                    if (![string]::IsNullOrEmpty($AvailabilitySetValue)) { $AVSET = $true }else { $AVSET = $false }
                    $BootDiagEnabled = Get-AZSCSafeProperty -InputObject $data -Path 'diagnosticsProfile.bootDiagnostics.enabled'
                    if ($BootDiagEnabled -eq $true) { $bootdg = $true }else { $bootdg = $false }
 
                    #Storage
                    $OsDiskVhdUri = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.osDisk.vhd.uri'
                    $OsDiskManagedDiskId = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.osDisk.managedDisk.id'
                    $DataDisksManagedIds = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.dataDisks.managedDisk.id'
 
                    # Both are left unassigned when the VM uses a managed OS disk that is not in
                    # $disk (a disk outside the queried scope, or simply no matching row) --
                    # the same reuse-the-previous-VM's-value hazard as $Autoupdate above.
                    $OSDisk = $null
                    $OSDiskSize = $null
 
                    if($OsDiskVhdUri)
                        {
                            $OSDisk = 'Custom VHD'
                            $OSDiskSize = Get-AZSCSafeProperty -InputObject $data -Path 'storageProfile.osDisk.diskSizeGB'
                        }
                    else
                        {
                            foreach ($VMDisk in $disk)
                                {
                                    if ($VMDisk.id -eq $OsDiskManagedDiskId)
                                        {
                                            $OSDisk = $VMDisk.sku.name
                                        }
                                    if ($VMDisk.id -eq $DataDisksManagedIds)
                                        {
                                            $OSDiskSize = $VMDisk.properties.diskSizeGB
                                        }
                                }
                        }
 
                    if ($DataDisksManagedIds)
                        {
                            if (@($DataDisksManagedIds).count -ge 2)
                            {
                                $StorAcc = (@($DataDisksManagedIds).count.ToString() + ' Disks found.')
                                foreach ($VMDisk in $disk)
                                    {
                                        if ($VMDisk.id -in $DataDisksManagedIds)
                                            {
                                                $dataSize = ($VMDisk.properties.diskSizeGB | Measure-Object -Sum).Sum
                                            }
                                    }
                            }
                            else
                            {
                                foreach ($VMDisk in $disk)
                                    {
                                        if ($VMDisk.id -eq $DataDisksManagedIds)
                                            {
                                                $StorAcc = $VMDisk.sku.name
                                                $dataSize = $VMDisk.properties.diskSizeGB
                                            }
                                    }
                            }
                        }
                    else
                        {
                            $StorAcc = 'None'
                            $dataSize = '0'
                        }
 
                    # The untagged case still has to emit ONE row per VM, which is why this is a
                    # sentinel rather than an empty collection. It used to be the string '0':
                    # harmless without StrictMode ([string]$Tag.Name on a string quietly yields
                    # ''), but a hard throw with it, since [string] has no Name member. An object
                    # carrying empty Name/Value produces byte-identical output and cannot throw.
                    # `$1.tags` itself is absent (not empty) on rows the Graph never tagged.
                    $TagProperties = Get-AZSCSafeProperty -InputObject $1 -Path 'tags'
                    $Tags = if($null -ne $TagProperties -and ![string]::IsNullOrEmpty($TagProperties.psobject.properties)){$TagProperties.psobject.properties}else{[pscustomobject]@{ Name = ''; Value = '' }}
                    # networkInterfaces is an ARRAY of {id} references -- when it is empty (a
                    # VM row that genuinely has none), enumerating .id off it throws under
                    # StrictMode the same way an empty $Resources match does (AB#5633's rule);
                    # fetch the array itself safely first, then only enumerate when non-empty.
                    $NetworkInterfaceRefs = Get-AZSCSafeProperty -InputObject $data -Path 'networkProfile.networkInterfaces'
                    $VMNICS = if ($NetworkInterfaceRefs) { $NetworkInterfaceRefs.id } else { '0' }
'@


    AdditionalRowLoops = @(
        @{
            Variable = '2'
            Source = '$VMNICS'
            Preamble = @'
$vmnic = foreach ($netinterface in $nic)
                            {
                                if ($netinterface.id -eq $2) { $netinterface }
                            }
                        $vmnic = $vmnic | Select-Object -Unique
 
                        # No matching NIC row (common for every VM this harness's fixtures do not
                        # also carry a NIC for) -- $vmnic is $null, and every chain below reads a
                        # property OFF it, which throws under StrictMode on a null base (AB#5667).
                        # Match the values each branch's own "not found" case already produced.
                        if ($vmnic) {
                            # ipConfigurations is an ARRAY -- .properties.publicIPAddress.id relies
                            # on PowerShell's member-enumeration-across-elements magic, which
                            # throws under StrictMode the moment any hop is absent on every element
                            # (e.g. a NIC with no public IP simply omits 'publicIPAddress' rather
                            # than setting it null -- AB#5667/AB#5633's rule). Get-AZSCCollectedValue
                            # walks the same chain one safe hop at a time instead.
                            $IpConfigs = Get-AZSCSafeProperty -InputObject $vmnic -Path 'properties.ipConfigurations'
                            $IpConfigProps = Get-AZSCCollectedValue -InputObject $IpConfigs -Name 'properties'
                            $PublicIpId = Get-AZSCCollectedValue -InputObject (Get-AZSCCollectedValue -InputObject $IpConfigProps -Name 'publicIPAddress') -Name 'id' | Select-Object -First 1
                            $SubnetId = Get-AZSCCollectedValue -InputObject (Get-AZSCCollectedValue -InputObject $IpConfigProps -Name 'subnet') -Name 'id' | Select-Object -First 1
 
                            $PIP = if(![string]::IsNullOrEmpty($PublicIpId)){$PublicIpId.split('/')[8]}else{''}
                            $VNET = if(![string]::IsNullOrEmpty($SubnetId)){$SubnetId.split('/')[8]}else{''}
                            $Subnet = if(![string]::IsNullOrEmpty($SubnetId)){$SubnetId.split('/')[10]} else {''}
                            $vmnet = foreach ($VMVnet in $VirtualNetwork)
                                {
                                    if ((Get-AZSCCollectedValue -InputObject (Get-AZSCSafeProperty -InputObject $VMVnet -Path 'subnets') -Name 'id') -eq $SubnetId) { $VMVnet }
                                }
                            # A subnet entry without an `id` is rare but real (an inline subnet
                            # definition on some API versions), and a bare $_.id in the filter
                            # throws under StrictMode for the whole VM rather than skipping that
                            # one entry.
                            # A subnet entry without an `id` -- or a $null entry -- is rare but
                            # real, and a bare $_.id in the filter throws under StrictMode for the
                            # whole VM rather than skipping that one entry. Get-AZSCSafeProperty
                            # rather than $_.PSObject.Properties['id'] because .PSObject itself is
                            # not safe to dereference on a $null element.
                            $vmnetsubnet = (Get-AZSCSafeProperty -InputObject $vmnet -Path 'properties.subnets') |
                                Where-Object { (Get-AZSCSafeProperty -InputObject $_ -Path 'id') -eq $SubnetId }
 
                            $NicDnsServers = Get-AZSCSafeProperty -InputObject $vmnic -Path 'properties.dnsSettings.dnsServers'
                            if(![string]::IsNullOrEmpty($NicDnsServers))
                                {
                                    $DNSServer = $NicDnsServers
                                }
                            else
                                {
                                    $DNSServer = Get-AZSCSafeProperty -InputObject $vmnet -Path 'properties.dhcpoptions.dnsservers'
                                }
 
                            if(![string]::IsNullOrEmpty($DNSServer))
                                {
                                    $FinalDNS = if (@($DNSServer).count -gt 1) { $DNSServer | ForEach-Object { $_ + ' ,' } }else { $DNSServer }
                                    $FinalDNS = [string]$FinalDNS
                                    $FinalDNS = if ($FinalDNS -like '* ,*') { $FinalDNS -replace ".$" }else { $FinalDNS }
                                    $FinalDNS = ('VNET: ( ' + $FinalDNS + ')')
                                }
                            else
                                {
                                    $FinalDNS = 'Default (Azure-provided)'
                                }
 
                            $NicNsgId = Get-AZSCSafeProperty -InputObject $vmnic -Path 'properties.networkSecurityGroup.id'
                            $SubnetNsgId = Get-AZSCSafeProperty -InputObject $vmnetsubnet -Path 'properties.networksecuritygroup.id'
                            if(![string]::IsNullOrEmpty($NicNsgId))
                                {
                                    $vmnsg = $NicNsgId.split('/')[8]
                                }
                            elseif(![string]::IsNullOrEmpty($SubnetNsgId))
                                {
                                    $vmnsg = ('Subnet: ('+$SubnetNsgId.split('/')[8]+')')
                                }
                            else
                                {
                                    $vmnsg = 'None'
                                }
                            $NicAcceleratedNetworking = Get-AZSCSafeProperty -InputObject $vmnic -Path 'properties.enableAcceleratedNetworking'
                            if(![string]::IsNullOrEmpty($NicAcceleratedNetworking))
                                {
                                    $AcceleratedNetwork = $true
                                }
                            else
                                {
                                    $AcceleratedNetwork = $false
                                }
                        } else {
                            $PIP = ''; $VNET = ''; $Subnet = ''; $vmnet = $null; $vmnetsubnet = $null
                            $DNSServer = $null; $FinalDNS = 'Default (Azure-provided)'
                            $vmnsg = 'None'; $AcceleratedNetwork = $false
                            # $IpConfigProps is read again further down (Private IP Address/
                            # Allocation) -- initialised here too so that read is never the FIRST
                            # assignment to it under StrictMode for a VM whose only NIC match
                            # failed (an unset variable throws exactly like a missing property).
                            $IpConfigProps = $null
                        }
 
                        # Operational values are pre-fetched once during collection and carried on
                        # a typed envelope. Keeping the old payload-level shaping here preserves
                        # the report contract without performing network I/O in a collector.
                        $OperationalEnvelope = @($Resources | Where-Object {
                            $_.TYPE -eq 'AZSC/Operational/VirtualMachine' -and $_.id -eq $1.id
                        }) | Select-Object -First 1
                        $Operational = Get-AZSCSafeProperty -InputObject $OperationalEnvelope -Path 'properties'
                        $avgCpu = 'N/A'; $avgMem = 'N/A'
                        $CpuValues = @(Get-AZSCSafeProperty -InputObject $Operational -Path 'CpuMetrics.value.timeseries.data.average' -Enumerate | Where-Object { $_ -ne $null })
                        if ($CpuValues) { $avgCpu = [math]::Round(($CpuValues | Measure-Object -Average).Average, 1) }
                        $MemoryValues = @(Get-AZSCSafeProperty -InputObject $Operational -Path 'MemoryMetrics.value.timeseries.data.average' -Enumerate | Where-Object { $_ -ne $null })
                        # Compute SKU responses occasionally repeat a capability value. The
                        # legacy row loop happened to receive one scalar; the pre-fetched
                        # envelope can preserve the repeated values as an array. Select one
                        # usable value before numeric conversion so an advisory payload cannot
                        # discard the entire VM collector (AB#6152).
                        $ramValue = @($RAM | Where-Object { $null -ne $_ } | ForEach-Object {
                            if ($_ -is [System.Collections.IEnumerable] -and $_ -isnot [string]) { @($_)[0] } else { $_ }
                        } | Select-Object -First 1)
                        $ramBytes = 0
                        if ($ramValue.Count -gt 0 -and $null -ne $ramValue[0]) {
                            try { $ramBytes = [double]$ramValue[0] * 1073741824 } catch { $ramBytes = 0 }
                        }
                        if ($MemoryValues -and $ramBytes -gt 0) {
                            $avgAvailBytes = ($MemoryValues | Measure-Object -Average).Average
                            $avgMem = [math]::Round((($ramBytes - $avgAvailBytes) / $ramBytes) * 100, 1)
                        }

                        $drReplicated = 'N/A'; $drTargetRegion = 'N/A'; $drHealth = 'N/A'
                        $Eligibility = Get-AZSCSafeProperty -InputObject $Operational -Path 'ReplicationEligibility'
                        if ((Get-AZSCSafeProperty -InputObject $Eligibility -Path 'properties.eligible') -eq $false -and
                            (Get-AZSCSafeProperty -InputObject $Eligibility -Path 'properties.errorDetails')) {
                            $drReplicated = 'Check ASR'
                        } elseif ((Get-AZSCSafeProperty -InputObject $Eligibility -Path 'properties.eligible') -eq $true) {
                            $drReplicated = $false
                        }
                        foreach ($ProtectedResponse in @(Get-AZSCSafeProperty -InputObject $Operational -Path 'ReplicationProtectedItems' -Enumerate)) {
                            $vmItem = @(Get-AZSCSafeProperty -InputObject $ProtectedResponse -Path 'value' -Enumerate | Where-Object {
                                (Get-AZSCSafeProperty -InputObject $_ -Path 'properties.friendlyName') -eq $1.NAME
                            }) | Select-Object -First 1
                            if ($vmItem) {
                                $drReplicated = $true
                                $RecoveryId = Get-AZSCSafeProperty -InputObject $vmItem -Path 'properties.recoveryAzureResourceGroupId'
                                $RecoverySegments = @([string]$RecoveryId -split '/')
                                $drTargetRegion = if ($RecoverySegments.Count -gt 4) { $RecoverySegments[4] } else { $null }
                                $drHealth = Get-AZSCSafeProperty -InputObject $vmItem -Path 'properties.replicationHealth'
                                break
                            }
                        }

                        $estMonthlyCost = 'N/A'
                        # Cost Management returns each row as an array (amount followed by
                        # dimensions/currency). Preserve the response envelope but take the
                        # amount cell explicitly; casting the full row makes one VM discard the
                        # entire collector under StrictMode.
                        $rawCost = @(Get-AZSCSafeProperty -InputObject $Operational -Path 'EstimatedCost.properties.rows' -Enumerate | Select-Object -First 1)
                        if ($rawCost.Count -gt 0) { $rawCost = $rawCost[0] }
                        if ($rawCost -is [System.Collections.IEnumerable] -and $rawCost -isnot [string] -and @($rawCost).Count -gt 0) { $rawCost = @($rawCost)[0] }
                        if ($null -ne $rawCost) {
                            try { $estMonthlyCost = [math]::Round([double]$rawCost, 2) } catch { $estMonthlyCost = 'N/A' }
                        }
'@

        }
    )

    TagLoop = @{
        Variable = 'Tag'
        Source = '$Tags'
        Preamble = ''
    }

    Fields = @(
        @{
            Name = 'ID'
            Expression = '$1.id'
        }
        @{
            Name = 'Subscription'
            Expression = '$SubscriptionName'
        }
        @{
            Name = 'Resource Group'
            Expression = '$1.RESOURCEGROUP'
        }
        @{
            Name = 'VM Name'
            Expression = '$1.NAME'
        }
        @{
            Name = 'Location'
            Expression = '$1.LOCATION'
        }
        @{
            Name = 'Retiring Feature'
            Expression = '$RetiringFeature'
        }
        @{
            Name = 'Retiring Date'
            Expression = '$RetiringDate'
        }
        @{
            Name = 'Availability Zone'
            Expression = '[string]$1.ZONES'
        }
        @{
            Name = 'Zones Available in the Region'
            Expression = '$VMZonesAvailable'
        }
        @{
            Name = 'Availability Set'
            Expression = '$AVSET'
        }
        @{
            Name = 'VM Size'
            Expression = '$VMSize'
        }
        @{
            Name = 'Remaining Quota (vCPUs)'
            Expression = '[string]$RemainingQuota'
        }
        @{
            Name = 'vCPUs'
            Expression = '$vCPUs'
        }
        @{
            Name = 'vCPUs Per Core'
            Expression = '$vCPUsPerCore'
        }
        @{
            Name = 'RAM (GiB)'
            Expression = '$RAM'
        }
        @{
            Name = 'Max Remote Storage Disks'
            Expression = '$MaxDataDiskCount'
        }
        @{
            Name = 'Uncached Disk IOPS Limit'
            Expression = '$UncachedDiskIOPS'
        }
        @{
            Name = 'Uncached Disk Throughput Limit (MB/s)'
            Expression = '$UncachedDiskBytesPerSecond'
        }
        @{
            Name = 'Max Network Interfaces'
            Expression = '$MaxNetworkInterfaces'
        }
        @{
            Name = 'Image Reference'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''storageProfile.imageReference.publisher'')'
        }
        @{
            Name = 'Image Version'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''storageProfile.imageReference.exactVersion'')'
        }
        @{
            Name = 'Capabilities'
            Expression = '$VMCapabilities'
        }
        @{
            Name = 'Hybrid Benefit'
            Expression = '$Lic'
        }
        @{
            Name = 'Admin Username'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''osProfile.adminUsername'')'
        }
        @{
            Name = 'OS Type'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''storageProfile.osDisk.osType'')'
        }
        @{
            Name = 'OS Name'
            Expression = '$OSName'
        }
        @{
            Name = 'OS Version'
            Expression = '$OSVersion'
        }
        @{
            Name = 'Automatic Update'
            Expression = '$Autoupdate'
        }
        @{
            Name = 'Boot Diagnostics'
            Expression = '$bootdg'
        }
        @{
            Name = 'Performance Agent'
            Expression = 'if ($azDiag -ne '''') { $true }else { $false }'
        }
        @{
            Name = 'Azure Monitor'
            Expression = 'if ($Azinsights -ne '''') { $true }else { $false }'
        }
        @{
            Name = 'OS Disk Storage Type'
            Expression = '$OSDisk'
        }
        @{
            Name = 'OS Disk Size (GB)'
            Expression = '$OSDiskSize'
        }
        @{
            Name = 'Data Disk Storage Type'
            Expression = '$StorAcc'
        }
        @{
            Name = 'Data Disk Size (GB)'
            Expression = '$dataSize'
        }
        @{
            Name = 'VM generation'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''extended.instanceview.hypervgeneration'')'
        }
        @{
            Name = 'Power State'
            Expression = '(Get-AZSCSafeProperty -InputObject $data -Path ''extended.instanceView.powerState.displayStatus'')'
        }
        @{
            Name = 'NIC Name'
            Expression = '[string](Get-AZSCSafeProperty -InputObject $vmnic -Path ''name'')'
        }
        @{
            Name = 'NIC Type'
            Expression = '[string](Get-AZSCSafeProperty -InputObject $vmnic -Path ''properties.nicType'')'
        }
        @{
            Name = 'DNS Servers'
            Expression = '$FinalDNS'
        }
        @{
            Name = 'Public IP'
            Expression = '$PIP'
        }
        @{
            Name = 'Virtual Network'
            Expression = '$VNET'
        }
        @{
            Name = 'Subnet'
            Expression = '$Subnet'
        }
        @{
            Name = 'NSG'
            Expression = '$vmnsg'
        }
        @{
            Name = 'Accelerated Networking'
            Expression = '$AcceleratedNetwork'
        }
        @{
            Name = 'IP Forwarding'
            Expression = '[string](Get-AZSCSafeProperty -InputObject $vmnic -Path ''properties.enableIPForwarding'')'
        }
        @{
            Name = 'Private IP Address'
            Expression = '[string](Get-AZSCCollectedValue -InputObject $IpConfigProps -Name ''privateIPAddress'' | Select-Object -First 1)'
        }
        @{
            Name = 'Private IP Allocation'
            Expression = '[string](Get-AZSCCollectedValue -InputObject $IpConfigProps -Name ''privateIPAllocationMethod'' | Select-Object -First 1)'
        }
        @{
            Name = 'Creation Time'
            Expression = '$timecreated'
        }
        @{
            Name = 'VM Extensions'
            Expression = '$ext'
        }
        @{
            Name = 'Avg CPU % (7d)'
            Expression = '$avgCpu'
        }
        @{
            Name = 'Avg Memory % (7d)'
            Expression = '$avgMem'
        }
        @{
            Name = 'DR Replicated'
            Expression = '$drReplicated'
        }
        @{
            Name = 'DR Target Region'
            Expression = '$drTargetRegion'
        }
        @{
            Name = 'DR Replication Health'
            Expression = '$drHealth'
        }
        @{
            Name = 'Est. Monthly Cost (USD)'
            Expression = '$estMonthlyCost'
        }
        @{
            Name = 'Resource U'
            Expression = '$ResUCount'
        }
        @{
            Name = 'Tag Name'
            Expression = '[string]$Tag.Name'
        }
        @{
            Name = 'Tag Value'
            Expression = '[string]$Tag.Value'
        }
    )

    Export = @{
        WorksheetName = 'Virtual Machines'
        TableNamePrefix = 'VMTable_'
        Columns = @(
            'Subscription'
            'Resource Group'
            'VM Name'
            'VM Size'
            'Remaining Quota (vCPUs)'
            'vCPUs'
            'vCPUs Per Core'
            'RAM (GiB)'
            'Max Remote Storage Disks'
            'Uncached Disk IOPS Limit'
            'Uncached Disk Throughput Limit (MB/s)'
            'Max Network Interfaces'
            'Retiring Feature'
            'Retiring Date'
            'Availability Zone'
            'Zones Available in the Region'
            'Capabilities'
            'Location'
            'OS Type'
            'OS Name'
            'OS Version'
            'Automatic Update'
            'Image Reference'
            'Image Version'
            'Hybrid Benefit'
            'Admin Username'
            'Boot Diagnostics'
            'Performance Agent'
            'Azure Monitor'
            'OS Disk Storage Type'
            'OS Disk Size (GB)'
            'Data Disk Storage Type'
            'Data Disk Size (GB)'
            'VM generation'
            'Power State'
            'Availability Set'
            'Virtual Network'
            'Subnet'
            'DNS Servers'
            'NSG'
            'NIC Name'
            'NIC Type'
            'Accelerated Networking'
            'IP Forwarding'
            'Private IP Address'
            'Private IP Allocation'
            'Public IP'
            'Creation Time'
            'VM Extensions'
            'Avg CPU % (7d)'
            'Avg Memory % (7d)'
            'DR Replicated'
            'DR Target Region'
            'DR Replication Health'
            'Est. Monthly Cost (USD)'
            'Resource U'
        )
        TagColumns = @(
            'Tag Name'
            'Tag Value'
        )
        TagColumnsBefore = $null
        NumberFormat = '0'
        ConditionalText = @(
            'New-ConditionalText false -Range V:V'
            'New-ConditionalText None -Range Y:Y'
            'New-ConditionalText false -Range AA:AA'
            'New-ConditionalText false -Range AB:AB'
            'New-ConditionalText false -Range AC:AC'
            'New-ConditionalText None -Range AN:AN'
            'New-ConditionalText false -Range AQ:AQ'
            'New-ConditionalText -Range M2:M100 -ConditionalType ContainsText'
        )
    }

    SourceCollector = 'Modules/Public/InventoryModules/Compute/VirtualMachine.ps1'
}