Public/Set-AzureUtilsTagInventory.ps1

function Set-AzureUtilsTagInventory {
    <#
        .SYNOPSIS
            Applies tag values from an inventory Excel file back to Azure resources.

        .DESCRIPTION
            Reads an .xlsx produced by (or shaped like) Export-AzureUtilsTagInventory
            and sets the tag values from the 'TAG_<name>' columns on each resource
            identified by its 'resourceId'. The operation is a MERGE:

              * tags present on the resource but absent from the file are kept;
              * a 'TAG_<name>' cell left empty is ignored (never created/changed);
              * a manually added 'TAG_<name>' column (with a value) creates that tag.

            Implemented with Update-AzTag -Operation Merge. Rows are grouped by
            subscription and the context is switched per group. This cmdlet changes
            Azure resources, so it supports -WhatIf and -Confirm.

            Requires Az.Resources (Update-AzTag) and ImportExcel. Messages are en-US.

        .PARAMETER InputPath
            Path to the .xlsx inventory file to read.

        .PARAMETER WorksheetName
            Worksheet to read. Defaults to 'TagInventory'.

        .PARAMETER Quiet
            Suppress the per-resource '[INFO] ...' lines (errors are still shown).

        .EXAMPLE
            Set-AzureUtilsTagInventory -InputPath 'C:\Temp\report.xlsx' -WhatIf

            Shows what would be changed without modifying anything.

        .EXAMPLE
            Set-AzureUtilsTagInventory 'C:\Temp\report.xlsx'

            Merges the tag values from the workbook onto the matching resources.

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

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [Alias('Path')]
        [ValidateScript({ if (-not (Test-Path -LiteralPath $_ -PathType Leaf)) { throw "Excel file not found: $_" }; $true })]
        [string] $InputPath,

        [string] $WorksheetName = 'TagInventory',

        [switch] $Quiet
    )

    $null = Assert-AzureUtilsContext

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

    # Read and parse the workbook into { ResourceId, Tags } records.
    $rows = Import-Excel -Path $InputPath -WorksheetName $WorksheetName -ErrorAction Stop

    $apply   = [System.Collections.Generic.List[object]]::new()
    $skipped = 0
    foreach ($record in ($rows | ConvertFrom-AzureUtilsTagExcelRow)) {
        if ([string]::IsNullOrWhiteSpace($record.ResourceId)) { continue }
        if ($record.Tags.Count -eq 0) { $skipped++; continue }   # nothing to set on this row
        $apply.Add($record)
    }

    # Group by subscription so the context can be switched once per subscription.
    $groups   = $apply | Group-Object { if ($_.ResourceId -match '/subscriptions/([0-9a-fA-F-]{36})') { $Matches[1] } else { '' } }
    $subCount = @($groups | Where-Object { $_.Name }).Count

    Write-Host ''
    Write-Host 'Azure Tag Inventory Apply' -ForegroundColor Cyan
    Write-Host ('-' * 51) -ForegroundColor DarkGray
    Write-Host (" Source: {0}" -f (Resolve-Path -LiteralPath $InputPath).Path)
    Write-Host (" Resources to update: {0}" -f $apply.Count)
    Write-Host (" Subscriptions: {0}" -f $subCount)
    if ($skipped -gt 0) { Write-Host (" Rows skipped (no tag values): {0}" -f $skipped) }
    Write-Host ''
    Write-Host 'Starting apply...'

    if ($apply.Count -eq 0) {
        Write-AzureUtilsStatus -Level WARN -Message 'No resources with tag values to apply.'
        return
    }

    $applied    = 0
    $errorCount = 0
    $n          = 0
    $original   = Get-AzContext

    try {
        foreach ($group in $groups) {
            $subId = $group.Name
            if ($subId) {
                try {
                    $null = Set-AzContext -SubscriptionId $subId -ErrorAction Stop
                }
                catch {
                    $errorCount += $group.Count
                    Write-AzureUtilsStatus -Level ERROR -Message ("Cannot switch to subscription {0}; skipping {1} resource(s): {2}" -f $subId, $group.Count, $_.Exception.Message)
                    $n += $group.Count
                    continue
                }
            }

            foreach ($rec in $group.Group) {
                $n++
                $summary = (($rec.Tags.Keys | Sort-Object | ForEach-Object { '{0}={1}' -f $_, $rec.Tags[$_] }) -join '; ')

                if ($PSCmdlet.ShouldProcess($rec.ResourceId, "Merge tags: $summary")) {
                    try {
                        $null = Update-AzTag -ResourceId $rec.ResourceId -Tag $rec.Tags -Operation Merge -ErrorAction Stop
                        $applied++
                        if (-not $Quiet) {
                            Write-AzureUtilsStatus -Level INFO -Message ("{0} of {1} set {2} tag(s) on {3}: {4}" -f $n, $apply.Count, $rec.Tags.Count, $rec.ResourceId, $summary)
                        }
                    }
                    catch {
                        $errorCount++
                        Write-AzureUtilsStatus -Level ERROR -Message ("{0}: {1}" -f $rec.ResourceId, $_.Exception.Message)
                    }
                }
            }
        }
    }
    finally {
        if ($original) { $null = Set-AzContext -Context $original -ErrorAction SilentlyContinue }
    }

    Write-AzureUtilsStatus -Level INFO -Message 'Apply Finish'
    Write-Host ("Updated {0} of {1} resource(s){2}." -f $applied, $apply.Count, $(if ($errorCount) { ", $errorCount error(s)" } else { '' }))
}