Private/ConvertTo-AACInventoryInsight.ps1

function ConvertTo-AACInventoryInsight {
    <#
    .SYNOPSIS
        Builds Get-AACInventory -Insight's view of the estate from the
        Get-AACInsightQuery rows: what it is made of, what is wasted or at
        risk, and how full the networks are.
    .DESCRIPTION
        Breakdowns (label -> count, largest first):
          VmSizes, OsFamilies (Windows, Linux), OsVersions (Windows Server
          2022, Ubuntu 22.04, ...), PowerStates, Hybrid (Azure VMs and Azure
          Arc-enabled servers), StorageReplication (LRS, ZRS, GRS, ...),
          DatabaseTiers (Azure SQL General Purpose, serverless, Cosmos DB, ...)
          and TagCoverage (the share of resources with each required tag -
          -RequiredTag - or else with the most used tags)
        Findings (AAC.InventoryFinding), each with its cost this month when
        the inventory has cost (-Item):
          Unattached disk, Unused public IP, Unused network interface,
          Stopped VM (still billed: stopped, not deallocated), Disconnected
          Arc server, Classic resource (retired), Subnet nearly full (80% of
          its usable IPs or more), Connection down (VPN or ExpressRoute not
          connected, circuit not provisioned), Empty resource group
        Machines (AAC.InventoryMachine), Subnets (AAC.InventorySubnet: used
        and usable IPs - Azure keeps 5 per subnet) and Connections
        (AAC.InventoryConnection).
 
        Returns @{ Breakdowns; Findings; Machines; Subnets; Connections;
        Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [hashtable] $Rows = @{},

        # The inventory's items: subscription names, costs, empty resource groups, tags.
        [AllowEmptyCollection()]
        [object[]] $Item = @(),

        [string[]] $RequiredTag = @()
    )

    $value = { param($Object, [string] $Key) if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Key)) { $Object[$Key] } } else { Get-AACPropertyValue -InputObject $Object -Name $Key } }
    $text = { param($Object, [string] $Key) $v = & $value $Object $Key; if ($null -eq $v) { '' } else { [string]$v } }
    $rowsOf = { param([string] $Name) @(if ($Rows.Contains($Name)) { $Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $breakdown = {
        param([object[]] $Labels)
        @($Labels | Where-Object { $_ } | Group-Object | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | ForEach-Object { [pscustomobject]@{ Label = $_.Name; Value = $_.Count } })
    }
    $names = @{}
    $byId = @{}
    foreach ($entry in $Item) {
        if ($entry.Level -eq 'Subscription') { $names[[string]$entry.SubscriptionId] = [string]$entry.Name }
        if ($entry.Id) { $byId[([string]$entry.Id).ToLowerInvariant()] = $entry }
    }
    $subscriptionOf = { param($Row) $id = (& $text $Row 'subscriptionId').ToLowerInvariant(); if ($names.Contains($id)) { $names[$id] } else { $id } }
    $findings = [System.Collections.Generic.List[object]]::new()
    $finding = {
        param([string] $Kind, [string] $Severity, $Row, [string] $Type, [string] $Detail, [string] $Name)
        $id = (& $text $Row 'id')
        $known = if ($id -and $byId.Contains($id.ToLowerInvariant())) { $byId[$id.ToLowerInvariant()] } else { $null }
        $findings.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventoryFinding'
                Finding          = $Kind
                Severity         = $Severity
                Resource         = $(if ($Name) { $Name } else { & $text $Row 'name' })
                Type             = $Type
                ResourceGroup    = & $text $Row 'resourceGroup'
                SubscriptionName = & $subscriptionOf $Row
                Detail           = $Detail
                CostMonthToDate  = $(if ($known -and $null -ne $known.CostMonthToDate -and $known.CostMonthToDate -gt 0) { $known.CostMonthToDate } else { $null })
                Currency         = $(if ($known -and $known.Currency -ne 'mixed') { $known.Currency } else { '' })
                ResourceId       = $id
            })
    }

    # --- Machines: size, OS, power, Azure or Arc -----------------------------------------------------------
    $osLabel = {
        param([string] $OsType, [string] $OsName, [string] $OsVersion, [string] $Offer, [string] $Sku)
        if ($OsName -match '(?i)windows') {
            if ($OsName -match '(?i)(Windows Server \d{4}(?: R2)?|Windows 1[01])') { return $Matches[1] }
            return $OsName.Trim()
        }
        if ($OsName) {
            $distro = (Get-Culture).TextInfo.ToTitleCase($OsName.Trim().ToLowerInvariant()) -replace '^Rhel', 'RHEL' -replace '^Sles', 'SLES'
            $release = if ($OsVersion -match '^(\d+(?:\.\d+)?)') { " $($Matches[1])" } else { '' }
            return "$distro$release"
        }
        if ($Offer -match '(?i)^windows') { return $(if ($Sku -match '(\d{4})') { "Windows Server $($Matches[1])" } else { 'Windows' }) }
        if ($Offer -match '(?i)ubuntu') { return $(if ($Sku -match '(\d{2})[_.](\d{2})') { "Ubuntu $($Matches[1]).$($Matches[2])" } else { 'Ubuntu' }) }
        if ($Offer -match '(?i)^rhel') { return $(if ($Sku -match '^(\d+)') { "RHEL $($Matches[1])" } else { 'RHEL' }) }
        if ($Offer) { return "$Offer $Sku".Trim() }
        if ($OsType) { return $OsType }
        'Unknown'
    }
    $powerLabel = @{ 'powerstate/running' = 'Running'; 'powerstate/deallocated' = 'Deallocated'; 'powerstate/stopped' = 'Stopped (still billed)'; 'powerstate/starting' = 'Starting'; 'powerstate/stopping' = 'Stopping'; 'powerstate/deallocating' = 'Deallocating' }
    $machines = [System.Collections.Generic.List[object]]::new()
    foreach ($row in (& $rowsOf 'insightVms')) {
        $power = (& $text $row 'power').ToLowerInvariant()
        $os = & $osLabel (& $text $row 'osType') (& $text $row 'osName') (& $text $row 'osVersion') (& $text $row 'offer') (& $text $row 'imageSku')
        $family = if ((& $text $row 'osType')) { & $text $row 'osType' } elseif ($os -match '(?i)windows') { 'Windows' } else { 'Linux' }
        $machines.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventoryMachine'
                Name             = & $text $row 'name'
                Kind             = 'Azure VM'
                Size             = & $text $row 'size'
                OsFamily         = $family
                Os               = $os
                PowerState       = $(if ($powerLabel.Contains($power)) { $powerLabel[$power] } else { 'Unknown' })
                Location         = & $text $row 'location'
                ResourceGroup    = & $text $row 'resourceGroup'
                SubscriptionName = & $subscriptionOf $row
                ResourceId       = & $text $row 'id'
            })
        if ($power -eq 'powerstate/stopped') { & $finding 'Stopped VM (still billed)' 'Medium' $row 'microsoft.compute/virtualmachines' 'Stopped from inside the OS, not deallocated: its compute is still billed. Deallocate it (Stop in the portal) if it is not needed.' }
    }
    foreach ($row in (& $rowsOf 'insightArc')) {
        $status = & $text $row 'status'
        $machines.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventoryMachine'
                Name             = & $text $row 'name'
                Kind             = 'Azure Arc server'
                Size             = ''
                OsFamily         = $(if ((& $text $row 'osType')) { (Get-Culture).TextInfo.ToTitleCase((& $text $row 'osType').ToLowerInvariant()) } else { '' })
                Os               = & $osLabel (& $text $row 'osType') (& $text $row 'osName') (& $text $row 'osVersion') '' (& $text $row 'osSku')
                PowerState       = $(if ($status) { "Arc: $status" } else { 'Unknown' })
                Location         = & $text $row 'location'
                ResourceGroup    = & $text $row 'resourceGroup'
                SubscriptionName = & $subscriptionOf $row
                ResourceId       = & $text $row 'id'
            })
        if ($status -and $status -ne 'Connected') { & $finding 'Disconnected Arc server' 'Medium' $row 'microsoft.hybridcompute/machines' "Status '$status': Azure no longer manages, monitors or patches it. Reconnect the agent, or remove the machine from Azure Arc." }
    }
    $azureVms = @($machines | Where-Object Kind -EQ 'Azure VM')
    $arc = @($machines | Where-Object Kind -EQ 'Azure Arc server')

    # --- Storage and databases --------------------------------------------------------------------------------
    $replication = @(foreach ($row in (& $rowsOf 'insightStorage')) {
            $sku = & $text $row 'sku'
            if ($sku -match '_(.+)$') { $Matches[1] -replace 'RAGRS', 'RA-GRS' -replace 'RAGZRS', 'RA-GZRS' } elseif ($sku) { $sku } else { 'Unknown' }
        })
    $dbTier = {
        param($Row)
        $type = & $text $Row 'type'; $sku = & $text $Row 'skuName'; $tier = & $text $Row 'skuTier'
        switch -Regex ($type) {
            'microsoft\.sql/servers/databases' {
                $kind = & $text $Row 'kind'
                $serverless = $sku -match '_S_' -or $kind -match 'serverless'
                "Azure SQL - $(if ($tier) { $tier } else { 'unknown tier' })$(if ($serverless) { ' (serverless)' } elseif ($sku -match '^(Basic|S\d|P\d)') { ' (DTU)' } else { '' })"
            }
            'microsoft\.sql/managedinstances' { "SQL Managed Instance - $(if ($tier) { $tier } else { $sku })" }
            'microsoft\.documentdb/databaseaccounts' { "Cosmos DB - $(if ((& $text $Row 'capabilities') -match 'EnableServerless') { 'serverless' } else { 'provisioned throughput' })" }
            'microsoft\.dbforpostgresql/flexibleservers' { "PostgreSQL - $(if ($tier) { $tier } else { $sku })" }
            'microsoft\.dbformysql/flexibleservers' { "MySQL - $(if ($tier) { $tier } else { $sku })" }
            default { $type }
        }
    }
    $databases = @(foreach ($row in (& $rowsOf 'insightDatabases')) { & $dbTier $row })

    # --- Waste: disks, public IPs, NICs -------------------------------------------------------------------------
    $unattachedGb = 0
    foreach ($row in (& $rowsOf 'insightDisks')) {
        if ((& $text $row 'state') -eq 'Unattached' -and -not (& $text $row 'managedBy')) {
            $unattachedGb += [int](& $value $row 'sizeGb')
            & $finding 'Unattached disk' 'Low' $row 'microsoft.compute/disks' "$([int](& $value $row 'sizeGb')) GB $(& $text $row 'sku'), attached to nothing and still billed. Snapshot it if needed, then delete it."
        }
    }
    foreach ($row in (& $rowsOf 'insightPublicIps')) {
        if (-not (& $text $row 'usedBy')) { & $finding 'Unused public IP' 'Low' $row 'microsoft.network/publicipaddresses' "$(& $text $row 'sku') $((& $text $row 'allocation').ToLowerInvariant()) public IP bound to nothing$(if ((& $text $row 'sku') -eq 'Standard' -or (& $text $row 'allocation') -eq 'Static') { ' - billed while it is reserved' }). Delete it if nothing will use it." }
    }
    foreach ($row in (& $rowsOf 'insightNics')) {
        if (-not (& $text $row 'usedBy')) { & $finding 'Unused network interface' 'Low' $row 'microsoft.network/networkinterfaces' 'Attached to no VM or private endpoint - usually left behind by a deleted VM. It holds a private IP; delete it.' }
    }
    foreach ($row in (& $rowsOf 'insightClassic')) {
        & $finding 'Classic resource' 'High' $row (& $text $row 'type') 'A classic (ASM) resource: classic VMs, Cloud Services and storage accounts are retired. Migrate it to Azure Resource Manager.'
    }
    foreach ($entry in @($Item | Where-Object { $_.Level -eq 'ResourceGroup' -and $_.Resources -eq 0 })) {
        $findings.Add([pscustomobject][ordered]@{
                PSTypeName = 'AAC.InventoryFinding'; Finding = 'Empty resource group'; Severity = 'Low'; Resource = $entry.Name; Type = 'microsoft.resources/resourcegroups'
                ResourceGroup = $entry.Name; SubscriptionName = $entry.SubscriptionName; Detail = 'No resources: delete it, unless a deployment or policy is about to use it.'; CostMonthToDate = $null; Currency = ''; ResourceId = $entry.Id
            })
    }

    # --- Subnets: used and usable IPs ----------------------------------------------------------------------------
    $subnets = @(foreach ($row in (& $rowsOf 'insightSubnets')) {
            $prefix = & $text $row 'prefix'
            $usable = if ($prefix -match '^\d+\.\d+\.\d+\.\d+/(\d+)$') { [Math]::Max(0, [Math]::Pow(2, 32 - [int]$Matches[1]) - 5) } else { $null }
            $used = [int](& $value $row 'used')
            $percent = if ($usable) { [Math]::Round(100 * $used / $usable) } else { $null }
            [pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventorySubnet'
                VirtualNetwork   = & $text $row 'vnet'
                Subnet           = & $text $row 'subnet'
                Prefix           = $prefix
                Usable           = $usable
                Used             = $used
                Free             = $(if ($null -ne $usable) { [Math]::Max(0, $usable - $used) } else { $null })
                UsedPercent      = $percent
                ResourceGroup    = & $text $row 'resourceGroup'
                SubscriptionName = & $subscriptionOf $row
                VirtualNetworkId = & $text $row 'vnetId'
            }
            if ($null -ne $percent -and $percent -ge 80) {
                & $finding 'Subnet nearly full' $(if ($percent -ge 95) { 'High' } else { 'Medium' }) @{ id = (& $text $row 'vnetId'); name = (& $text $row 'vnet'); resourceGroup = (& $text $row 'resourceGroup'); subscriptionId = (& $text $row 'subscriptionId') } 'microsoft.network/virtualnetworks/subnets' "$used of $usable usable IPs in $prefix are in use ($percent%). Scale-outs and new private endpoints will fail when it is full." "$(& $text $row 'vnet') / $(& $text $row 'subnet')"
            }
        })
    $subnets = @($subnets | Sort-Object -Property @{ Expression = { if ($null -eq $_.UsedPercent) { -1 } else { $_.UsedPercent } }; Descending = $true }, VirtualNetwork, Subnet)

    # --- Hybrid connectivity -------------------------------------------------------------------------------------------
    $connections = @(
        foreach ($row in (& $rowsOf 'insightConnections')) {
            $status = & $text $row 'status'
            $kind = switch (& $text $row 'connectionType') { 'ExpressRoute' { 'ExpressRoute connection' } 'IPsec' { 'Site-to-site VPN' } 'Vnet2Vnet' { 'VNet-to-VNet VPN' } default { & $text $row 'connectionType' } }
            [pscustomobject][ordered]@{ PSTypeName = 'AAC.InventoryConnection'; Name = & $text $row 'name'; Kind = $kind; Status = $(if ($status) { $status } else { 'Unknown' }); Detail = "Provisioning: $(& $text $row 'state')"; ResourceGroup = & $text $row 'resourceGroup'; SubscriptionName = & $subscriptionOf $row; ResourceId = & $text $row 'id' }
            if ($status -and $status -ne 'Connected') { & $finding 'Connection down' 'High' $row 'microsoft.network/connections' "$kind is '$status'." }
        }
        foreach ($row in (& $rowsOf 'insightCircuits')) {
            $provider = & $text $row 'providerState'
            [pscustomobject][ordered]@{ PSTypeName = 'AAC.InventoryConnection'; Name = & $text $row 'name'; Kind = 'ExpressRoute circuit'; Status = $(if ($provider -eq 'Provisioned') { 'Provisioned' } elseif ($provider) { $provider } else { 'Unknown' }); Detail = "$(& $text $row 'provider') $([int](& $value $row 'bandwidth')) Mbps; circuit $(& $text $row 'circuitState')"; ResourceGroup = & $text $row 'resourceGroup'; SubscriptionName = & $subscriptionOf $row; ResourceId = & $text $row 'id' }
            if ($provider -and $provider -ne 'Provisioned') { & $finding 'Connection down' 'High' $row 'microsoft.network/expressroutecircuits' "The provider's side of the circuit is '$provider' - traffic can't flow until the provider provisions it." }
        }
    )

    # --- Tag coverage -------------------------------------------------------------------------------------------------
    $resources = @($Item | Where-Object Level -EQ 'Resource')
    $tagsOf = { param([string] $Tags) @(if ($Tags) { $Tags -split '; ' | ForEach-Object { ($_ -split '=', 2)[0] } }) }
    $tagNames = if ($RequiredTag.Count) { @($RequiredTag) } else {
        @($resources | ForEach-Object { & $tagsOf $_.Tags } | Group-Object | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First 6 | ForEach-Object Name)
    }
    $coverage = @(foreach ($tag in $tagNames) {
            $with = @($resources | Where-Object { (& $tagsOf $_.Tags) -contains $tag }).Count
            [pscustomobject]@{ Label = $tag; Value = $(if ($resources.Count) { [Math]::Round(100 * $with / $resources.Count) } else { 0 }); Resources = $with }
        })
    $tagCompliant = if ($RequiredTag.Count) { @($resources | Where-Object { $have = & $tagsOf $_.Tags; -not @($RequiredTag | Where-Object { $have -notcontains $_ }).Count }).Count } else { $null }

    $severityRank = @{ High = 0; Medium = 1; Low = 2 }
    $allFindings = @($findings | Sort-Object -Property @{ Expression = { $severityRank[$_.Severity] } }, Finding, SubscriptionName, Resource)
    $wasteCost = 0.0
    $wasteCurrencies = @{}
    foreach ($each in $allFindings) { if ($null -ne $each.CostMonthToDate) { $wasteCost += $each.CostMonthToDate; if ($each.Currency) { $wasteCurrencies[$each.Currency] = $true } } }
    @{
        Breakdowns  = [ordered]@{
            VmSizes            = & $breakdown @($azureVms | ForEach-Object Size)
            OsFamilies         = & $breakdown @($machines | ForEach-Object { if ($_.OsFamily) { $_.OsFamily } else { 'Unknown' } })
            OsVersions         = & $breakdown @($machines | ForEach-Object Os)
            PowerStates        = & $breakdown @($azureVms | ForEach-Object PowerState)
            Hybrid             = @(
                if ($azureVms.Count) { [pscustomobject]@{ Label = 'Azure VMs'; Value = $azureVms.Count } }
                $connected = @($arc | Where-Object PowerState -EQ 'Arc: Connected').Count
                if ($connected) { [pscustomobject]@{ Label = 'Arc servers (connected)'; Value = $connected } }
                if ($arc.Count - $connected) { [pscustomobject]@{ Label = 'Arc servers (not connected)'; Value = $arc.Count - $connected } }
            )
            StorageReplication = & $breakdown $replication
            DatabaseTiers      = & $breakdown $databases
            TagCoverage        = $coverage
        }
        Findings    = $allFindings
        Machines    = $machines.ToArray()
        Subnets     = $subnets
        Connections = $connections
        Stats       = @{
            AzureVms         = $azureVms.Count
            ArcServers       = $arc.Count
            Running          = @($azureVms | Where-Object PowerState -EQ 'Running').Count
            Deallocated      = @($azureVms | Where-Object PowerState -EQ 'Deallocated').Count
            StoppedBilled    = @($azureVms | Where-Object PowerState -EQ 'Stopped (still billed)').Count
            UnattachedDisks  = @($allFindings | Where-Object Finding -EQ 'Unattached disk').Count
            UnattachedDiskGb = $unattachedGb
            UnusedPublicIps  = @($allFindings | Where-Object Finding -EQ 'Unused public IP').Count
            UnusedNics       = @($allFindings | Where-Object Finding -EQ 'Unused network interface').Count
            Classic          = @($allFindings | Where-Object Finding -EQ 'Classic resource').Count
            FullSubnets      = @($allFindings | Where-Object Finding -EQ 'Subnet nearly full').Count
            ConnectionsDown  = @($allFindings | Where-Object Finding -EQ 'Connection down').Count
            Findings         = $allFindings.Count
            WasteCost        = $(if ($wasteCurrencies.Count -eq 1) { [Math]::Round($wasteCost, 2) } else { $null })
            WasteCurrency    = $(if ($wasteCurrencies.Count -eq 1) { @($wasteCurrencies.Keys)[0] } else { '' })
            RequiredTags     = @($RequiredTag)
            TagCompliant     = $tagCompliant
            Resources        = $resources.Count
        }
    }
}