Public/Export-AACResource.ps1

function Export-AACResource {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Exports your Azure resources - for the whole tenant, a management group,
        subscriptions, resource groups or resource types - to CSV, HTML and PDF,
        with the subscription and management group by name, the tags as columns
        and each resource's key settings flattened into plain values.
    .DESCRIPTION
        What Resource Graph Explorer's "Download as CSV" doesn't give you:
 
          Where subscription name and state, the management group and its
                      path up to the root, resource group and its tags, location
                      and zones - one Resource Graph query, joined to
                      resourcecontainers
          What type, kind, SKU and capacity, the parent of a child
                      resource, identity, provisioning state, public network
                      access, managed by, created (when Azure records it)
          Details the settings people look up, per type: a VM's size, OS,
                      image and power state; a disk's size, state and VM; a
                      storage account's TLS, public access and network rules; a
                      NIC's IP, VM and NSG; an App Service's plan, runtime and
                      host; an AKS cluster's version and nodes; a key vault's
                      soft delete, purge protection and RBAC... about 25 types
          Tags one column per tag key ('Tag: Environment'), so the CSV
                      sorts and filters in a spreadsheet - and the tags as one
                      'key=value; ...' column too
          -Flatten every property as a column of its own
                      ('properties.networkAcls.defaultAction'), for the deep
                      dives; best with -ResourceType
 
        Filters run in Resource Graph where they can (so only what you asked
        for comes back): -ResourceType, -ResourceGroupName, -Name (exact, or
        with wildcards), -Location, -Tag (a value, or '*' for any), -MissingTag
        (resources without a tag - for tag governance) and -Filter, a KQL
        condition of your own. -Search then looks for text anywhere in the name,
        type, group, subscription, location, details, tags and ID.
 
        Large estates are read in parallel: the subscriptions are split across
        up to four Resource Graph queries, each paged to the end. Read-only;
        Reader is enough.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only these resource groups; wildcards work.
    .PARAMETER ResourceType
        Only these types (for example 'microsoft.compute/virtualmachines', or
        'microsoft.network/*'); wildcards work.
    .PARAMETER Name
        Only resources with these names; wildcards work.
    .PARAMETER Location
        Only resources in these regions (for example uksouth, 'West Europe').
    .PARAMETER Tag
        Only resources with these tags: @{ Environment = 'prod' }, or
        @{ Owner = '*' } for any value.
    .PARAMETER MissingTag
        Only resources without these tags.
    .PARAMETER Search
        Only resources with this text in their name, type, resource group,
        subscription, location, details, tags or ID.
    .PARAMETER Filter
        A Kusto (KQL) condition Resource Graph applies, as '| where <Filter>' -
        for example "tostring(properties.provisioningState) != 'Succeeded'".
    .PARAMETER TagColumn
        The tag keys that get a column of their own. Default: every tag key
        found, the most used first.
    .PARAMETER Flatten
        Add every property, flattened, as a column of its own.
    .PARAMETER First
        Stop after this many resources (after the filters).
    .PARAMETER CsvPath
        Write the resources to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the resources.
    .PARAMETER NoDisplay
        Return the resources without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Export-AACResource -CsvPath .\out\Resources.csv
        Every resource you can see, with names, management groups, tags as columns and details.
    .EXAMPLE
        Export-AACResource -ManagementGroupId mg-landingzones -HtmlPath .\out\LandingZones.html -PdfPath .\out\LandingZones.pdf
        One management group's resources as an interactive report and a PDF.
    .EXAMPLE
        Export-AACResource -ResourceType 'microsoft.compute/virtualmachines' -NoDisplay | Select-Object Name, Subscription, Location, Details
        Every VM with its size, OS, image and power state.
    .EXAMPLE
        Export-AACResource -MissingTag CostCenter, Owner -CsvPath .\out\Untagged.csv
        The resources without a CostCenter or Owner tag, for the owners to fix.
    .EXAMPLE
        Export-AACResource -ResourceType 'microsoft.storage/storageaccounts' -Flatten -CsvPath .\out\Storage.csv
        Every storage account with every property as a column.
    .EXAMPLE
        Export-AACResource -Tag @{ Environment = 'prod' } -Search 'sql'
        Production resources with 'sql' anywhere in them.
    .OUTPUTS
        AAC.Resource
    #>

    [CmdletBinding()]
    [OutputType('AAC.Resource')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [SupportsWildcards()]
        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $ResourceType,

        [SupportsWildcards()]
        [string[]] $Name,

        [string[]] $Location,

        [hashtable] $Tag,

        [string[]] $MissingTag,

        [string] $Search,

        [string] $Filter,

        [string[]] $TagColumn,

        [switch] $Flatten,

        [ValidateRange(1, [int]::MaxValue)]
        [int] $First,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Azure resources',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolvePath = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ })
        Query          = Get-AACResourceExportQuery -ResourceType $ResourceType -ResourceGroupName $ResourceGroupName -Name $Name -Location $Location -Tag $Tag -MissingTag $MissingTag -Filter $Filter
        Flatten        = [bool]$Flatten
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Resources' -Color 'deepskyblue3_1' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"

        # Up to four queries side by side, each over its share of the
        # subscriptions (and never more than 1,000 subscriptions in one).
        $ids = @($scope.Ids)
        $parts = [Math]::Max([Math]::Min(4, $ids.Count), [Math]::Ceiling($ids.Count / 1000))
        $queries = [ordered]@{}
        if ($parts -le 1) { $queries['resources'] = @{ Query = $request.Query; SubscriptionId = $ids } }
        else {
            for ($p = 0; $p -lt $parts; $p++) {
                $share = @(for ($i = $p; $i -lt $ids.Count; $i += $parts) { $ids[$i] })
                $queries["resources$($p + 1)"] = @{ Query = $request.Query; SubscriptionId = $share }
            }
        }
        Update-AACProgress -Id 'read' -Total $queries.Count -Description "Reading the resources of $($ids.Count) subscription(s)"
        $read = Invoke-AACGraphBatch -Query $queries -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read part $Done of $Total"
        }
        $rows = [System.Collections.Generic.List[object]]::new()
        foreach ($key in $queries.Keys) { foreach ($row in @($read.Rows[$key])) { if ($null -ne $row) { $rows.Add($row) } } }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} resource(s)' -f $rows.Count)
        Update-AACProgress -Id 'flatten' -Indeterminate -Description 'Flattening tags, management groups and settings'
        $result = ConvertTo-AACResourceExport -Row $rows.ToArray() -Flatten:$request.Flatten
        Update-AACProgress -Id 'flatten' -Complete -Description ('{0:N0} resource(s) of {1:N0} type(s)' -f $result.Stats.Resources, $result.Stats.Types)
        @{ Result = $result; Scope = $scope }
    }

    # --- What Resource Graph couldn't filter --------------------------------------------------------------------------
    $resources = @($outcome.Result.Resources)
    $like = { param([string] $Value, [string[]] $Pattern) foreach ($p in $Pattern) { if ($Value -like $p) { return $true } }; $false }
    if ($ResourceGroupName) { $resources = @($resources | Where-Object { & $like $_.ResourceGroup $ResourceGroupName }) }
    if ($ResourceType) { $resources = @($resources | Where-Object { & $like $_.ResourceType $ResourceType }) }
    if ($Name) { $resources = @($resources | Where-Object { & $like $_.Name $Name }) }
    if ($Search) {
        $text = "*$Search*"
        $resources = @($resources | Where-Object { $_.Name -like $text -or $_.ResourceType -like $text -or $_.ResourceGroup -like $text -or $_.Subscription -like $text -or $_.Location -like $text -or $_.Details -like $text -or $_.TagText -like $text -or $_.ResourceId -like $text })
    }
    if ($First) { $resources = @($resources | Select-Object -First $First) }

    # --- Flat rows: the CSV, and the report's table ------------------------------------------------------------------
    $tagKeys = if ($TagColumn) { @($TagColumn) } else { @($outcome.Result.TagKeys) }
    $propertyKeys = @()
    if ($Flatten) {
        $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $propertyKeys = @(foreach ($r in $resources) { foreach ($k in $r.Properties.Keys) { if ($seen.Add($k)) { $k } } })
    }
    $fixed = 'Name', 'ResourceType', 'Kind', 'ResourceGroup', 'Subscription', 'ManagementGroup', 'ManagementGroupPath', 'Location', 'Zones', 'Sku', 'Capacity', 'Identity', 'ProvisioningState', 'PublicNetworkAccess', 'Parent', 'ManagedBy', 'Created', 'Details', 'TagText', 'TagCount', 'ResourceGroupTags', 'SubscriptionId', 'SubscriptionState', 'ManagementGroupId', 'ResourceGroupId', 'ResourceId'
    $rows = @(foreach ($r in $resources) {
            $flat = [ordered]@{}
            foreach ($k in $fixed) { $flat[$(if ($k -eq 'TagText') { 'Tags' } else { $k })] = $r.$k }
            # Tag keys differ in case between resources (Environment, environment): matched without case.
            $tags = @{}
            foreach ($k in $r.Tags.Keys) { $tags[$k.ToLowerInvariant()] = $r.Tags[$k] }
            foreach ($k in $tagKeys) { $flat["Tag: $k"] = [string]$tags[$k.ToLowerInvariant()] }
            foreach ($k in $propertyKeys) { $flat["properties.$k"] = $(if ($r.Properties.Contains($k)) { $r.Properties[$k] } else { $null }) }
            [pscustomobject]$flat
        })

    # --- The report ------------------------------------------------------------------------------------------------------
    $count = { param([object[]] $Items, [string] $Property) @($Items | Group-Object -Property $Property -NoElement | Sort-Object Count -Descending) }
    $types = & $count $resources 'ResourceType'
    $untagged = @($resources | Where-Object { -not $_.TagCount }).Count
    $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 }
    $subscriptions = & $distinct @($resources | ForEach-Object { $_.SubscriptionId })
    $groups = & $distinct @($resources | ForEach-Object { $_.ResourceGroupId })
    $regions = & $distinct @($resources | ForEach-Object { $_.Location })
    $topTags = @($tagKeys | Select-Object -First 8)
    $tagCoverage = @(foreach ($k in $topTags) {
            $n = @($rows | Where-Object { $_."Tag: $k" }).Count
            if ($resources.Count) { @{ Label = $k; Value = [Math]::Round(100 * $n / $resources.Count, 1) } }
        })
    $filters = @(
        if ($ResourceType) { "type $($ResourceType -join ', ')" }
        if ($ResourceGroupName) { "resource group $($ResourceGroupName -join ', ')" }
        if ($Name) { "name $($Name -join ', ')" }
        if ($Location) { "location $($Location -join ', ')" }
        if ($Tag) { 'tag ' + (@($Tag.Keys | ForEach-Object { "$_=$($Tag[$_])" }) -join ', ') }
        if ($MissingTag) { "missing tag $($MissingTag -join ', ')" }
        if ($Search) { "text '$Search'" }
        if ($Filter) { "where $Filter" }
    )
    $columns = @(
        @{ Key = 'Name'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
        @{ Key = 'ResourceType'; Label = 'Type'; Type = 'type'; Facet = $true; Console = $true; Pdf = $true }
        @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true; Console = $true; Pdf = $true }
        @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true; Console = $true }
        @{ Key = 'Location'; Label = 'Location'; Facet = $true; Console = $true; Pdf = $true }
        @{ Key = 'Sku'; Label = 'SKU'; Facet = $true }
        @{ Key = 'Details'; Label = 'Details'; Type = 'wide'; Pdf = $true }
        @{ Key = 'Tags'; Label = 'Tags'; Type = 'wide' }
        @{ Key = 'ManagementGroup'; Label = 'Management group'; Facet = $true }
        @{ Key = 'ManagementGroupPath'; Label = 'Management group path'; Hidden = $true }
        @{ Key = 'Kind'; Label = 'Kind'; Facet = $true; Hidden = $true }
        @{ Key = 'Identity'; Label = 'Identity'; Facet = $true }
        @{ Key = 'ProvisioningState'; Label = 'Provisioning'; Facet = $true; Hidden = $true }
        @{ Key = 'PublicNetworkAccess'; Label = 'Public access'; Facet = $true; Hidden = $true }
        @{ Key = 'Zones'; Label = 'Zones'; Hidden = $true }
        @{ Key = 'Parent'; Label = 'Parent'; Hidden = $true }
        @{ Key = 'Created'; Label = 'Created'; Type = 'date'; Hidden = $true }
    ) + @($topTags | ForEach-Object { @{ Key = "Tag: $_"; Label = "Tag: $_"; Facet = $true } })
    $report = @{
        Subtitle = 'Resources with their subscription, management group, tags and key settings'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; Filters = $(if ($filters.Count) { $filters -join '; ' } else { 'none' }) }
        Status   = 'Info'
        Headline = $(if ($resources.Count) { "{0:N0} resource(s) of {1:N0} type(s) in {2:N0} resource group(s) across {3:N0} subscription(s); {4:N0} untagged" -f $resources.Count, $types.Count, $groups, $subscriptions, $untagged } else { 'No resources match.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $resources.Count; Label = 'resources'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $types.Count; Label = 'types'; Tone = 'violet' }
            @{ Value = '{0:N0}' -f $subscriptions; Label = 'subscriptions'; Tone = 'neutral' }
            @{ Value = '{0:N0}' -f $groups; Label = 'resource groups'; Tone = 'neutral' }
            @{ Value = '{0:N0}' -f $regions; Label = 'locations'; Tone = 'neutral' }
            @{ Value = '{0:N0}' -f $untagged; Label = 'untagged'; Tone = $(if ($untagged) { 'warn' } else { 'good' }) }
        )
        Charts   = @(
            @{ Title = 'Top types'; Items = @($types | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'resources'; Column = 'ResourceType'; Tone = 'violet'; Console = $true }
            @{ Title = 'By location'; Items = @(& $count $resources 'Location' | Select-Object -First 10 | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(none)' }); Value = $_.Count; Filter = $_.Name } }); Table = 'resources'; Column = 'Location'; Tone = 'info'; Console = $true }
            @{ Title = 'By subscription'; Items = @(& $count $resources 'Subscription' | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'resources'; Column = 'Subscription'; Tone = 'neutral' }
            @{ Title = 'Tag coverage (% of resources)'; Items = $tagCoverage; Tone = 'good' }
        )
        Tables   = @(
            @{ Id = 'resources'; Title = 'Resources'; Section = 'Resources'; Rows = $rows; Noun = 'resources'; GroupBy = @('ResourceType', 'ResourceGroup', 'Subscription', 'Location', 'ManagementGroup'); ConsoleLimit = 30
                Empty = 'No resources match.'; Columns = $columns }
        )
        Hint     = '-ResourceType, -ResourceGroupName, -Tag, -MissingTag, -Search and -Filter narrow it; -Flatten adds every property; -CsvPath for a spreadsheet, -HtmlPath and -PdfPath for reports.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun 'resource' -CsvPath (& $resolvePath $CsvPath) -HtmlPath (& $resolvePath $HtmlPath) -PdfPath (& $resolvePath $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $resources -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}