Private/ConvertTo-AACResourceExport.ps1

function ConvertTo-AACResourceExport {
    <#
    .SYNOPSIS
        Turns Export-AACResource's Resource Graph rows into resources an admin can
        read: the subscription and management group by name, the tags as a
        dictionary, and the settings that matter for each type in one Details
        line.
    .DESCRIPTION
        No Azure calls. -Row is the rows of Get-AACResourceExportQuery.
 
        Each resource (AAC.Resource) has where it is (subscription, its
        management group and the path up to the root, resource group, location,
        zones), what it is (type, kind, SKU, parent for child resources), how it
        is set up (identity, provisioning state, public network access, created
        when Azure records it) and Details: a dozen common types get the
        settings people look up most - a VM's size, OS, image and power state, a
        storage account's TLS, public access and network rules, an App Service's
        plan and runtime, an AKS cluster's version and nodes, and so on.
 
        -Flatten adds Properties: every property, flattened by
        ConvertTo-AACFlatObject.
 
        Returns @{ Resources; TagKeys (the tag keys, most used first); Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyCollection()]
        [object[]] $Row = @(),

        [switch] $Flatten
    )

    # The settings shown in Details, per type: label, path, and how to show it
    # (leaf: the last part of an ID; count: how many; join: a list on one line;
    # gb: bytes as GB; sum.<field>: a field added up across a list).
    $details = @{
        'microsoft.compute/virtualmachines'           = @(@('Size', 'properties.hardwareProfile.vmSize'), @('OS', 'properties.storageProfile.osDisk.osType'), @('OS name', 'properties.extended.instanceView.osName'), @('Image', 'properties.storageProfile.imageReference.offer'), @('Image SKU', 'properties.storageProfile.imageReference.sku'), @('Power', 'properties.extended.instanceView.powerState.displayStatus'), @('Data disks', 'properties.storageProfile.dataDisks', 'count'), @('Priority', 'properties.priority'), @('License', 'properties.licenseType'), @('Availability set', 'properties.availabilitySet.id', 'leaf'))
        'microsoft.compute/virtualmachinescalesets'   = @(@('Size', 'sku.name'), @('Instances', 'sku.capacity'), @('Orchestration', 'properties.orchestrationMode'), @('Upgrade policy', 'properties.upgradePolicy.mode'))
        'microsoft.compute/disks'                     = @(@('Size (GiB)', 'properties.diskSizeGB'), @('State', 'properties.diskState'), @('Attached to', 'managedBy', 'leaf'), @('OS', 'properties.osType'), @('Encryption', 'properties.encryption.type'), @('Network access', 'properties.networkAccessPolicy'))
        'microsoft.storage/storageaccounts'           = @(@('Access tier', 'properties.accessTier'), @('HTTPS only', 'properties.supportsHttpsTrafficOnly'), @('Minimum TLS', 'properties.minimumTlsVersion'), @('Blob public access', 'properties.allowBlobPublicAccess'), @('Shared key', 'properties.allowSharedKeyAccess'), @('Default network action', 'properties.networkAcls.defaultAction'), @('Data Lake', 'properties.isHnsEnabled'))
        'microsoft.network/publicipaddresses'         = @(@('IP', 'properties.ipAddress'), @('Allocation', 'properties.publicIPAllocationMethod'), @('Attached to', 'properties.ipConfiguration.id', 'owner'))
        'microsoft.network/networkinterfaces'         = @(@('Private IP', 'properties.ipConfigurations.0.properties.privateIPAddress'), @('VM', 'properties.virtualMachine.id', 'leaf'), @('NSG', 'properties.networkSecurityGroup.id', 'leaf'), @('Subnet', 'properties.ipConfigurations.0.properties.subnet.id', 'leaf'), @('Accelerated networking', 'properties.enableAcceleratedNetworking'), @('IP forwarding', 'properties.enableIPForwarding'))
        'microsoft.network/virtualnetworks'           = @(@('Address space', 'properties.addressSpace.addressPrefixes', 'join'), @('Subnets', 'properties.subnets', 'count'), @('DNS servers', 'properties.dhcpOptions.dnsServers', 'join'), @('Peerings', 'properties.virtualNetworkPeerings', 'count'), @('DDoS protection', 'properties.enableDdosProtection'))
        'microsoft.network/networksecuritygroups'     = @(@('Rules', 'properties.securityRules', 'count'), @('Subnets', 'properties.subnets', 'count'), @('NICs', 'properties.networkInterfaces', 'count'))
        'microsoft.network/loadbalancers'             = @(@('Frontends', 'properties.frontendIPConfigurations', 'count'), @('Backend pools', 'properties.backendAddressPools', 'count'), @('Rules', 'properties.loadBalancingRules', 'count'), @('Probes', 'properties.probes', 'count'))
        'microsoft.network/applicationgateways'       = @(@('SKU', 'properties.sku.name'), @('Capacity', 'properties.sku.capacity'), @('Minimum instances', 'properties.autoscaleConfiguration.minCapacity'), @('State', 'properties.operationalState'), @('WAF', 'properties.webApplicationFirewallConfiguration.enabled'))
        'microsoft.network/privateendpoints'          = @(@('Target', 'properties.privateLinkServiceConnections.0.properties.privateLinkServiceId', 'leaf'), @('Sub-resource', 'properties.privateLinkServiceConnections.0.properties.groupIds', 'join'), @('Connection', 'properties.privateLinkServiceConnections.0.properties.privateLinkServiceConnectionState.status'), @('Subnet', 'properties.subnet.id', 'leaf'))
        'microsoft.web/sites'                         = @(@('State', 'properties.state'), @('Host', 'properties.defaultHostName'), @('Plan', 'properties.serverFarmId', 'leaf'), @('Runtime', 'properties.siteConfig.linuxFxVersion'), @('HTTPS only', 'properties.httpsOnly'), @('Public access', 'properties.publicNetworkAccess'))
        'microsoft.web/serverfarms'                   = @(@('Apps', 'properties.numberOfSites'), @('Instances', 'sku.capacity'), @('Linux', 'properties.reserved'), @('Zone redundant', 'properties.zoneRedundant'))
        'microsoft.sql/servers'                       = @(@('Version', 'properties.version'), @('Public access', 'properties.publicNetworkAccess'), @('Minimum TLS', 'properties.minimalTlsVersion'), @('Entra-only auth', 'properties.administrators.azureADOnlyAuthentication'))
        'microsoft.sql/servers/databases'             = @(@('Status', 'properties.status'), @('Max size (GB)', 'properties.maxSizeBytes', 'gb'), @('Zone redundant', 'properties.zoneRedundant'), @('Backup redundancy', 'properties.currentBackupStorageRedundancy'), @('Elastic pool', 'properties.elasticPoolId', 'leaf'))
        'microsoft.keyvault/vaults'                   = @(@('Soft delete', 'properties.enableSoftDelete'), @('Purge protection', 'properties.enablePurgeProtection'), @('RBAC', 'properties.enableRbacAuthorization'), @('Public access', 'properties.publicNetworkAccess'), @('Retention (days)', 'properties.softDeleteRetentionInDays'))
        'microsoft.containerservice/managedclusters'  = @(@('Kubernetes', 'properties.kubernetesVersion'), @('Node pools', 'properties.agentPoolProfiles', 'count'), @('Nodes', 'properties.agentPoolProfiles', 'sum.count'), @('Network plugin', 'properties.networkProfile.networkPlugin'), @('Private', 'properties.apiServerAccessProfile.enablePrivateCluster'))
        'microsoft.documentdb/databaseaccounts'       = @(@('Consistency', 'properties.consistencyPolicy.defaultConsistencyLevel'), @('Regions', 'properties.locations', 'count'), @('Public access', 'properties.publicNetworkAccess'), @('Free tier', 'properties.enableFreeTier'))
        'microsoft.operationalinsights/workspaces'    = @(@('SKU', 'properties.sku.name'), @('Retention (days)', 'properties.retentionInDays'), @('Daily cap (GB)', 'properties.workspaceCapping.dailyQuotaGb'))
        'microsoft.recoveryservices/vaults'           = @(@('Redundancy', 'properties.redundancySettings.standardTierStorageRedundancy'), @('Soft delete', 'properties.securitySettings.softDeleteSettings.softDeleteState'), @('Immutability', 'properties.securitySettings.immutabilitySettings.state'))
        'microsoft.cache/redis'                       = @(@('SKU', 'properties.sku.name'), @('Capacity', 'properties.sku.capacity'), @('Version', 'properties.redisVersion'), @('Non-TLS port', 'properties.enableNonSslPort'))
        'microsoft.containerregistry/registries'      = @(@('Admin user', 'properties.adminUserEnabled'), @('Public access', 'properties.publicNetworkAccess'), @('Zone redundancy', 'properties.zoneRedundancy'))
        'microsoft.hybridcompute/machines'            = @(@('OS', 'properties.osName'), @('Version', 'properties.osVersion'), @('Status', 'properties.status'), @('Agent', 'properties.agentVersion'))
    }

    # A dotted path into parsed JSON; a number indexes a list. Exact keys first,
    # then case-insensitively (Azure's casing isn't consistent).
    $resolve = {
        param($Object, [string] $Path)
        foreach ($part in $Path.Split('.')) {
            if ($null -eq $Object) { return $null }
            if ($Object -is [System.Collections.IList] -and $part -match '^\d+$') { $Object = if ([int]$part -lt $Object.Count) { $Object[[int]$part] } else { $null }; continue }
            if ($Object -isnot [System.Collections.IDictionary]) { return $null }
            if ($Object.Contains($part)) { $Object = $Object[$part]; continue }
            $found = $null
            foreach ($key in $Object.Keys) { if ($key -eq $part) { $found = $Object[$key]; break } }
            $Object = $found
        }
        , $Object
    }
    $leaf = { param([string] $Id) if ($Id) { ($Id.TrimEnd('/') -split '/')[-1] } else { '' } }
    $show = {
        param($Value, [string] $How)
        switch -Wildcard ($How) {
            'leaf' { return (& $leaf ([string]$Value)) }
            'owner' { return (& $leaf ([string]$Value -replace '/(ipConfigurations|frontendIPConfigurations)/[^/]+$', '')) }
            'count' { return $(if ($null -eq $Value) { '' } else { @($Value).Count }) }
            'join' { return (@($Value | Where-Object { $null -ne $_ -and '' -ne $_ }) -join ', ') }
            'gb' { return $(if ($Value) { [Math]::Round([double]$Value / 1GB, 1) } else { '' }) }
            'sum.*' { $field = $How.Substring(4); $total = 0; foreach ($item in @($Value)) { $n = & $resolve $item $field; if ($n) { $total += [double]$n } }; return $total }
        }
        if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string] -and $Value -isnot [System.Collections.IDictionary]) { return (@($Value) -join ', ') }
        $Value
    }
    $date = {
        param($Value)
        if ($Value -is [datetime]) { return $Value }
        $d = [datetime]::MinValue
        if ($Value -and [datetime]::TryParse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null }
    }
    $tagsOf = {
        param($Bag)
        $tags = [ordered]@{}
        if ($Bag -is [System.Collections.IDictionary]) { foreach ($key in @($Bag.Keys | Sort-Object)) { $tags[[string]$key] = [string]$Bag[$key] } }
        $tags
    }

    $tagUse = @{}       # tag key (lower case) -> resources with it
    $tagName = @{}      # tag key (lower case) -> the spelling seen first
    $resources = foreach ($entry in $Row) {
        if ($entry -isnot [System.Collections.IDictionary]) { continue }
        $id = [string]$entry['id']
        $type = ([string]$entry['type']).ToLowerInvariant()
        $properties = $entry['properties']
        $chain = @($entry['managementGroupChain'] | Where-Object { $_ -is [System.Collections.IDictionary] })
        $tags = & $tagsOf $entry['tags']
        foreach ($key in $tags.Keys) {
            $lower = $key.ToLowerInvariant()
            $tagUse[$lower] = 1 + [int]$tagUse[$lower]
            if (-not $tagName.Contains($lower)) { $tagName[$lower] = $key }
        }
        $groupTags = & $tagsOf $entry['groupTags']
        # The chain runs from the subscription's own group up to the root; the path reads root first.
        $names = [string[]]@($chain | ForEach-Object { [string]$_['displayName'] })
        [array]::Reverse($names)
        $groupPath = $names -join ' > '
        $sku = $entry['sku']
        # Most types have no entry in $details: their Details stay empty.
        $line = if ($details.Contains($type)) {
            @(foreach ($d in $details[$type]) {
                    $value = & $show (& $resolve $entry $d[1]) $(if ($d.Count -gt 2) { $d[2] } else { '' })
                    if ($null -ne $value -and '' -ne "$value") { "$($d[0]): $value" }
                }) -join '; '
        }
        else { '' }
        $created = @(foreach ($field in 'creationTime', 'timeCreated', 'createdTime', 'createdAt', 'creationDate', 'createdDate') { & $date (& $resolve $properties $field) }) | Where-Object { $_ } | Select-Object -First 1
        # A child resource (servers/databases) is named after its parent too.
        $segments = ($id -split '/providers/', 2)[-1] -split '/'
        $item = [pscustomobject][ordered]@{
            Name                = [string]$entry['name']
            ResourceType        = $type
            Kind                = [string]$entry['kind']
            ResourceGroup       = [string]$entry['resourceGroup']
            Subscription        = $(if ($entry['subscriptionName']) { [string]$entry['subscriptionName'] } else { [string]$entry['subscriptionId'] })
            ManagementGroup     = $(if ($chain.Count) { [string]$chain[0]['displayName'] } else { '' })
            ManagementGroupPath = $groupPath
            Location            = [string]$entry['location']
            Zones               = (@($entry['zones'] | Where-Object { $_ }) -join ', ')
            Sku                 = $(if ($sku -is [System.Collections.IDictionary]) { (@($sku['name'], $sku['tier'] | Where-Object { $_ } | Select-Object -Unique) -join ' / ') } else { '' })
            Capacity            = $(if ($sku -is [System.Collections.IDictionary] -and $null -ne $sku['capacity']) { $sku['capacity'] } else { $null })
            Identity            = $(if ($entry['identity'] -is [System.Collections.IDictionary] -and $entry['identity']['type']) { [string]$entry['identity']['type'] } else { 'None' })
            ProvisioningState   = [string](& $resolve $properties 'provisioningState')
            PublicNetworkAccess = [string](& $resolve $properties 'publicNetworkAccess')
            Parent              = $(if ($segments.Count -gt 4) { $segments[-3] } else { '' })
            ManagedBy           = & $leaf ([string]$entry['managedBy'])
            Created             = $created
            Details             = $line
            Tags                = $tags
            TagText             = (@($tags.Keys | ForEach-Object { "$_=$($tags[$_])" }) -join '; ')
            TagCount            = $tags.Count
            ResourceGroupTags   = (@($groupTags.Keys | ForEach-Object { "$_=$($groupTags[$_])" }) -join '; ')
            SubscriptionId      = [string]$entry['subscriptionId']
            SubscriptionState   = [string]$entry['subscriptionState']
            ManagementGroupId   = $(if ($chain.Count) { [string]$chain[0]['name'] } else { '' })
            ResourceGroupId     = "/subscriptions/$($entry['subscriptionId'])/resourceGroups/$($entry['resourceGroup'])"
            ResourceId          = $id
        }
        if ($Flatten) { $item | Add-Member -NotePropertyName Properties -NotePropertyValue (ConvertTo-AACFlatObject -InputObject $properties) }
        $item.PSObject.TypeNames.Insert(0, 'AAC.Resource')
        $item
    }
    $resources = @($resources | Sort-Object -Property Subscription, ResourceGroup, ResourceType, Name)
    $tagKeys = @($tagUse.Keys | Sort-Object -Property @{ Expression = { $tagUse[$_] }; Descending = $true }, @{ Expression = { $_ } } | ForEach-Object { $tagName[$_] })

    $distinct = { param([string[]] $Values) $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase); foreach ($v in $Values) { if ($v) { $null = $set.Add($v) } }; $set.Count }
    @{
        Resources = $resources
        TagKeys   = $tagKeys
        Stats     = @{
            Resources      = $resources.Count
            Types          = & $distinct $resources.ResourceType
            Subscriptions  = & $distinct $resources.SubscriptionId
            ResourceGroups = & $distinct $resources.ResourceGroupId
            Locations      = & $distinct $resources.Location
            Untagged       = @($resources | Where-Object { -not $_.TagCount }).Count
        }
    }
}