source/Private/ArmDeploymentProgress.ps1

function Invoke-RJArmDeploymentWithProgress {
    <#
    .SYNOPSIS
    Deploys an ARM template as a background job and reports each resource as it completes.
 
    .DESCRIPTION
    Everything a Log Analytics Workspace (or Automation Account) deployment creates - the
    workspace, custom tables, Data Collection Rules, role assignments - comes from a single
    ARM deployment, so PowerShell only learns what's happening by polling ARM's own deployment
    operations. This submits the deployment as a job and polls Get-AzResourceGroupDeploymentOperation
    while it runs, announcing each resource with a friendly name derived from its ARM type as
    "Configuring <name>..." then "<name> configured" (or a Write-Warning on failure), recursing
    into nested deployments (e.g. the DCR bicep module, which ARM represents as its own
    Microsoft.Resources/deployments resource).
 
    .PARAMETER ResourceGroupName
    The target resource group.
 
    .PARAMETER TemplatePath
    Path to the JSON ARM template file.
 
    .PARAMETER DeploymentName
    Name for the deployment (for tracking in Azure portal).
 
    .PARAMETER Parameters
    Optional hashtable of template parameters.
 
    .OUTPUTS
    The deployment result object, as returned by Receive-Job.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ResourceGroupName,
        [Parameter(Mandatory)][string]$TemplatePath,
        [Parameter(Mandatory)][string]$DeploymentName,
        [hashtable]$Parameters = @{}
    )

    $job = New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -Name $DeploymentName `
        -TemplateFile $TemplatePath -TemplateParameterObject $Parameters -AsJob -ErrorAction Stop `
        -Verbose:($VerbosePreference -eq 'Continue') `
        -Debug:($DebugPreference -eq 'Continue')

    $seen = @{}  # resourceId -> 'started' | 'done'

    function Report([string]$Deployment) {
        foreach ($op in @(Get-AzResourceGroupDeploymentOperation -ResourceGroupName $ResourceGroupName `
                    -DeploymentName $Deployment -ErrorAction SilentlyContinue)) {
            $id = $op.TargetResource
            if (-not $id) { continue }

            # Nested deployment (e.g. the DCR module) - recurse into its operations. The nested
            # deployment itself is never announced, but its failure message is: a failure at the
            # module level (e.g. template validation) would otherwise show up nowhere until the
            # final Receive-Job error.
            if ($id -match '/providers/Microsoft\.Resources/deployments/([^/]+)$') {
                $nestedDeployment = $Matches[1]
                if ($op.ProvisioningState -eq 'Failed' -and $seen[$id] -ne 'done') {
                    $seen[$id] = 'done'
                    Write-Warning "Nested deployment '$nestedDeployment' failed: $($op.StatusMessage)"
                }
                Report $nestedDeployment
                continue
            }

            # Role assignment IDs end in a GUID that identifies nothing useful - use the
            # resource they're scoped to instead (e.g. the workspace or DCR being assigned to).
            if ($id -match '^(.+)/providers/Microsoft\.Authorization/roleAssignments/[^/]+$') {
                $name = "Role assignment on '$((($Matches[1]) -split '/')[-1])'"
            }
            # Friendly names for the small set of resource types this deployment actually
            # creates, pre-capitalized (used both mid-sentence and standalone below - simpler
            # than deriving two casings from one string). Anything else (e.g. an Automation
            # Account permission resource) falls through to the plain quoted-name form below -
            # unlabelled, but never wrong.
            # The table pattern must be checked before the workspace pattern, since a table's
            # ID also contains ".../workspaces/..." as a substring.
            elseif ($id -match '/providers/Microsoft\.OperationalInsights/workspaces/[^/]+/tables/([^/]+)$') {
                $name = "Custom table '$($Matches[1])'"
            }
            elseif ($id -match '/providers/Microsoft\.OperationalInsights/workspaces/([^/]+)$') {
                $name = "Log Analytics Workspace '$($Matches[1])'"
            }
            elseif ($id -match '/providers/Microsoft\.Insights/dataCollectionRules/([^/]+)$') {
                $name = "Data collection rule '$($Matches[1])'"
            }
            elseif ($id -match '/providers/Microsoft\.Automation/automationAccounts/([^/]+)$') {
                $name = "Automation account '$($Matches[1])'"
            }
            else {
                $name = "'$(($id -split '/')[-1])'"
            }

            if (-not $seen[$id]) {
                $seen[$id] = 'started'
                Write-Information " Configuring $name..."
            }

            if ($seen[$id] -eq 'done' -or $op.ProvisioningState -notin 'Succeeded', 'Failed') { continue }
            $seen[$id] = 'done'

            if ($op.ProvisioningState -eq 'Succeeded') {
                Write-Information " $name configured"
            }
            else {
                Write-Warning "$name failed: $($op.StatusMessage)"
            }
        }
    }

    try {
        # A freshly created job can still be 'NotStarted' for a moment - treat it like 'Running',
        # otherwise the loop is skipped and no progress is ever reported.
        while ($job.State -in 'NotStarted', 'Running') {
            Report $DeploymentName
            Start-Sleep -Seconds $ArmDeploymentPollIntervalSeconds
        }
        Report $DeploymentName   # final sweep for anything that finished after the last poll
        return Receive-Job -Job $job -Wait -ErrorAction Stop
    }
    finally {
        # Only discards the local job object. If the user interrupted (Ctrl+C), the ARM deployment
        # itself keeps running in Azure - it can be followed in the portal under the resource group's
        # Deployments blade.
        Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
    }
}