Public/Export-AzureUtilsTagInventory.ps1

function Export-AzureUtilsTagInventory {
    <#
        .SYNOPSIS
            Exports an Azure resource + tag inventory to an Excel workbook.

        .DESCRIPTION
            Reads every Azure resource in scope via Azure Resource Graph and writes
            an .xlsx workbook with the fixed columns resourceId, Resource Name,
            Sub Name, Resource Group Name, Resource Type, Region, followed by one
            'TAG_<name>' column per tag. By default every tag key found is exported;
            -FilterTags restricts the export to the listed keys (in the given order).

            Scope can be narrowed by management group(s), subscription(s), resource
            group(s) and a case-insensitive -NameContains match. Progress is shown
            on the console as a leveled report; the cmdlet does not emit objects to
            the pipeline.

            Requires Az.ResourceGraph and the ImportExcel module. Messages are en-US.

        .PARAMETER ManagementGroupId
            One or more management groups to inventory.

        .PARAMETER SubscriptionId
            One or more subscription IDs. Default: every enabled subscription in the
            current context.

        .PARAMETER ResourceGroupName
            Filter by one or more resource group names (case-insensitive).

        .PARAMETER NameContains
            Keep only resources whose name contains this text (case-insensitive).

        .PARAMETER FilterTags
            Export only these tag keys (one 'TAG_<name>' column each, in this order).
            When omitted, every tag key found is exported.

        .PARAMETER IncludeSubscription
            Also inventory the subscriptions themselves (as extra rows) with their
            own tags. Tags applied at subscription scope are otherwise not captured.

        .PARAMETER IncludeResourceGroup
            Also inventory the resource groups themselves (as extra rows) with their
            own tags. Tags applied at resource-group scope are otherwise not captured.

        .PARAMETER OutputPath
            Destination .xlsx file path.

        .PARAMETER TableStyle
            Excel table style name (ImportExcel). Defaults to a neutral 'Light1'.

        .PARAMETER Quiet
            Suppress the per-resource '[INFO] N of TOTAL ...' lines. The header,
            summary, error lines and final status are still shown.

        .EXAMPLE
            Export-AzureUtilsTagInventory -OutputPath '.\inventory.xlsx'

            Inventories every enabled subscription in the current context and writes
            an Excel workbook with one column per tag found.

        .EXAMPLE
            Export-AzureUtilsTagInventory -ManagementGroupId 'PLAT' `
                -FilterTags 'costCenter', 'environment' `
                -OutputPath 'C:\Temp\report.xlsx'

            Inventories a management group, exporting only the 'costCenter' and
            'environment' tags (as columns TAG_costCenter, TAG_environment, in that
            order).

        .EXAMPLE
            Export-AzureUtilsTagInventory -SubscriptionId $sub1, $sub2 `
                -ResourceGroupName 'rg-prod' -NameContains 'sql' `
                -OutputPath '.\sql-prod.xlsx' -Quiet

            Limits the scope to two subscriptions, the 'rg-prod' resource group and
            resources whose name contains 'sql', and suppresses the per-resource log
            (a progress bar is shown instead).

        .EXAMPLE
            Export-AzureUtilsTagInventory -ManagementGroupId 'PLAT' `
                -IncludeSubscription -IncludeResourceGroup `
                -OutputPath '.\full.xlsx'

            Inventories resources and also adds the subscriptions and resource groups
            themselves (as rows), so tags applied at those scopes are captured too.

        .LINK
            https://github.com/hendersonandrade/powershell-module-azureUtils
    #>

    [CmdletBinding(DefaultParameterSetName = 'Subscriptions')]
    param(
        [Parameter(ParameterSetName = 'ManagementGroup', Mandatory)]
        [string[]] $ManagementGroupId,

        [Parameter(ParameterSetName = 'Subscriptions')]
        [string[]] $SubscriptionId,

        [string[]] $ResourceGroupName,

        [string] $NameContains,

        [string[]] $FilterTags,

        # Also inventory the subscriptions / resource groups themselves (their own
        # tags), as extra rows from the resourcecontainers table.
        [switch] $IncludeSubscription,

        [switch] $IncludeResourceGroup,

        [Parameter(Mandatory)]
        [string] $OutputPath,

        [string] $TableStyle = 'Light1',

        [switch] $Quiet
    )

    $null = Assert-AzureUtilsContext

    if (-not (Get-Command -Name 'Export-Excel' -ErrorAction SilentlyContinue)) {
        if (Get-Module -ListAvailable -Name 'ImportExcel' -ErrorAction SilentlyContinue) {
            Import-Module ImportExcel -ErrorAction Stop
        }
        else {
            throw [System.Management.Automation.RuntimeException]::new(
                "Export-AzureUtilsTagInventory requires the 'ImportExcel' module. Install it with: Install-Module ImportExcel -Scope CurrentUser"
            )
        }
    }

    # Resolve scope, the subscriptionId -> name lookup, and a friendly scope label.
    $scope   = @{}
    $nameMap = @{}
    if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
        $scope['ManagementGroup'] = $ManagementGroupId
        foreach ($s in (Resolve-AzureUtilsSubscription)) { $nameMap[$s.Id] = $s.Name }  # best-effort names
        $scopeLabel = $ManagementGroupId -join ', '
        $scopeType  = 'Management Group'
    }
    else {
        $subs = Resolve-AzureUtilsSubscription -SubscriptionId $SubscriptionId
        if ($subs.Count -eq 0) {
            Write-AzureUtilsStatus -Level WARN -Message 'No accessible subscriptions in scope; nothing to export.'
            return
        }
        foreach ($s in $subs) { $nameMap[$s.Id] = $s.Name }
        $scope['Subscription'] = @($subs.Id)
        $scopeLabel = if ($SubscriptionId) { ($subs.Name -join ', ') } else { 'All enabled subscriptions' }
        $scopeType  = 'Subscription'
    }

    $baseQuery = New-AzureUtilsResourceQuery -ResourceGroupName $ResourceGroupName -NameContains $NameContains

    # Build the set of queries to run: resources, plus optionally the resource
    # group and subscription containers (so their own tags are inventoried too).
    $queries = [System.Collections.Generic.List[string]]::new()
    $queries.Add("$baseQuery`n| project id, name, type, resourceGroup, location, subscriptionId, tags")
    if ($IncludeResourceGroup) {
        $queries.Add((New-AzureUtilsContainerQuery -ContainerType ResourceGroup -NameContains $NameContains -ResourceGroupName $ResourceGroupName))
    }
    if ($IncludeSubscription) {
        $queries.Add((New-AzureUtilsContainerQuery -ContainerType Subscription -NameContains $NameContains))
    }

    # ---- Collect (tags arrive with the query); count from the data so the header
    # totals are exact. A progress bar gives live feedback during pagination. ----
    $records   = [System.Collections.Generic.List[object]]::new()
    $errors    = [System.Collections.Generic.List[string]]::new()
    $collected = 0
    $activity  = 'Collecting Azure resources from Azure Resource Graph'

    foreach ($query in $queries) {
        Write-Verbose "Query:`n$query"
        Invoke-AzureUtilsGraphQuery -Query $query @scope | ForEach-Object {
            try {
                $records.Add(($_ | ConvertTo-AzureUtilsTagRecord -SubscriptionName $nameMap))
                $collected++
                if ($collected % 50 -eq 0) {
                    Write-Progress -Id 1 -Activity $activity -Status "$collected items collected" -PercentComplete -1
                }
            }
            catch {
                $errors.Add($_.Exception.Message)
            }
        }
    }
    Write-Progress -Id 1 -Activity $activity -Completed

    $total = $records.Count
    $subCount = if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
        ($records | ForEach-Object { $_.SubscriptionId } | Where-Object { $_ } | Sort-Object -Unique).Count
    }
    else {
        $subs.Count
    }

    # ---- Report header (exact totals) ----
    Write-Host ''
    Write-Host 'Azure Tag Inventory Export' -ForegroundColor Cyan
    Write-Host ('-' * 51) -ForegroundColor DarkGray
    Write-Host (" Scope: {0} [{1}]" -f $scopeLabel, $scopeType)
    Write-Host (" Number of Subscriptions: {0}" -f $subCount)
    Write-Host (" Number of Resources: {0}" -f $total)
    Write-Host ''
    Write-Host 'Starting export...'

    # ---- Per-resource report (suppressed by -Quiet; errors always shown) ----
    if (-not $Quiet) {
        $n = 0
        foreach ($rec in $records) {
            $n++
            Write-AzureUtilsStatus -Level INFO -Message ("{0} of {1} collecting tags of {2}" -f $n, $total, $rec.ResourceId)
        }
    }
    foreach ($message in $errors) {
        Write-AzureUtilsStatus -Level ERROR -Message $message
    }

    Write-AzureUtilsStatus -Level INFO -Message 'Collect Finish'

    if ($records.Count -eq 0) {
        Write-AzureUtilsStatus -Level WARN -Message 'No resources found in scope; nothing to export.'
        return
    }

    # ---- Write the workbook ----
    $excelRows = @(Get-AzureUtilsTagExcelRow -Record $records -TagKey $FilterTags)
    $excelRows | Export-Excel -Path $OutputPath -WorksheetName 'TagInventory' `
        -TableName 'TagInventory' -TableStyle $TableStyle -AutoSize -FreezeTopRow -BoldTopRow

    Write-Host ("Report exported to {0}" -f $OutputPath)
}