Public/Test-AACAzureLimit.ps1

function Test-AACAzureLimit {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Shows which scopes are close to an Azure limit - role assignments,
        custom roles, policy assignments, definitions, initiatives and
        exemptions, resource groups, tags, management group depth - before
        a deployment fails on one.
    .DESCRIPTION
        Counts, read-only and in Azure Resource Graph (so only the totals
        come back, however large the tenant), what each scope uses of each
        limit:
          Tenant custom roles (5,000), custom initiatives
                             (2,500), management groups (10,000) and their
                             depth (6 levels below the root)
          Management group role assignments (500), policy assignments
                             (200), custom policy definitions (500),
                             custom initiatives (200), exemptions (1,000)
          Subscription role assignments at or below it (4,000),
                             policy assignments, definitions, initiatives
                             and exemptions, resource groups (980), tags on
                             the subscription and the most on any resource
                             group or resource (50)
          Resource group policy assignments and exemptions - listed
                             only when they reach the warning level
        Each (AAC.AzureLimit) has its use, the maximum, the percentage, what
        is left and - when it is close - what to do. Critical at
        -CriticalPercent (95%) or more, Warning at -WarningPercent (80%).
 
        The maximums are Microsoft's published limits; if one changes, or
        support raised one for you, pass -Maximum with the new number.
        Reader on the scope is enough; management groups count when one of
        the subscriptions in scope sits under them.
    .PARAMETER SubscriptionId
        Only these subscriptions (and the management groups above them).
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER Status
        Only these: Critical, Warning, OK.
    .PARAMETER WarningPercent
        Warning from this percentage of a limit. Default 80.
    .PARAMETER CriticalPercent
        Critical from this percentage of a limit. Default 95.
    .PARAMETER Maximum
        Your own maximums, by limit name - for a limit support raised, or one
        Microsoft changed: @{ RoleAssignmentsPerSubscription = 6000 }. The
        names: RoleAssignmentsPerSubscription,
        RoleAssignmentsPerManagementGroup, CustomRolesPerTenant,
        PolicyAssignmentsPerScope, PolicyDefinitionsPerScope,
        PolicySetDefinitionsPerScope, PolicySetDefinitionsPerTenant,
        PolicyExemptionsPerScope, ResourceGroupsPerSubscription,
        TagsPerSubscription, TagsPerResourceGroup, TagsPerResource,
        ManagementGroupDepth, ManagementGroupsPerTenant.
    .PARAMETER FailOnLimit
        Stop with an error when any limit is Critical (or, with
        -FailOnLimit Warning, at Warning) - a gate for a pipeline.
    .PARAMETER CsvPath
        Write the limits 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 limits.
    .PARAMETER NoDisplay
        Return the limits without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Test-AACAzureLimit
        Every limit in use across the tenant, the closest first.
    .EXAMPLE
        Test-AACAzureLimit -Status Critical, Warning -NoDisplay | Select-Object Limit, Scope, Used, Maximum, Percent
        Only the limits that need attention, as objects.
    .EXAMPLE
        Test-AACAzureLimit -ManagementGroupId 'mg-landingzones' -FailOnLimit Warning
        A pipeline gate: fails if anything under the landing zones is at 80% of a limit.
    .EXAMPLE
        Test-AACAzureLimit -Maximum @{ RoleAssignmentsPerSubscription = 6000 } -HtmlPath .\Limits.html
        With a raised role assignment limit, as a report.
    .OUTPUTS
        AAC.AzureLimit
    #>

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

        [string[]] $ManagementGroupId,

        [ValidateSet('Critical', 'Warning', 'OK')]
        [string[]] $Status,

        [ValidateRange(1, 100)]
        [int] $WarningPercent = 80,

        [ValidateRange(1, 100)]
        [int] $CriticalPercent = 95,

        [hashtable] $Maximum = @{},

        [ValidateSet('Critical', 'Warning')]
        [string] $FailOnLimit,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Azure limits',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

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

    if ($WarningPercent -gt $CriticalPercent) { throw "Azure Admin Console: -WarningPercent ($WarningPercent) is above -CriticalPercent ($CriticalPercent). Use a lower warning level." }
    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); Maximum = $Maximum; WarningPercent = $WarningPercent; CriticalPercent = $CriticalPercent }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Azure limits' -Color 'orange1' }
    $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)"
        $query = Get-AACAzureLimitQuery
        Update-AACProgress -Id 'count' -Total $query.Count -Description 'Counting what each scope uses'
        $read = Invoke-AACGraphBatch -Query $query -SubscriptionId $scope.GraphScope -AllowFailure @($query.Keys) -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'count' -Increment 1 -Description "Counted the $QueryName ($Done of $Total)"
        }
        Update-AACProgress -Id 'count' -Complete -Description "Counted $($query.Count - $read.Errors.Count) of $($query.Count) kinds of limit"
        @{ Result = (ConvertTo-AACAzureLimit -Read $read -Scope $scope -Maximum $request.Maximum -WarningPercent $request.WarningPercent -CriticalPercent $request.CriticalPercent); Scope = $scope }
    }

    $limits = @($outcome.Result.Limits)
    $all = $limits
    if ($Status) { $limits = @($limits | Where-Object { $Status -contains $_.Status }) }
    $critical = @($all | Where-Object Status -EQ 'Critical').Count
    $warning = @($all | Where-Object Status -EQ 'Warning').Count
    $tones = @{ Critical = 'bad'; Warning = 'warn'; OK = 'good' }
    $report = @{
        Subtitle = 'Azure limits: what each scope uses of the role assignment, policy, resource group, tag and management group limits'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; Warning = "$WarningPercent%"; Critical = "$CriticalPercent%"; Maximums = $(if ($Maximum.Count) { 'Microsoft''s, with ' + (@($Maximum.Keys | ForEach-Object { "$_ = $($Maximum[$_])" }) -join ', ') } else { 'Microsoft''s published limits' }) }
        Status   = $(if ($critical) { 'Failed' } elseif ($warning) { 'Warning' } else { 'Success' })
        Headline = $(if ($critical -or $warning) { "$critical limit(s) critical and $warning close (of $($all.Count) in use) - the closest: $(@($all)[0].Limit) on $(@($all)[0].Scope), $(@($all)[0].Used) of $(@($all)[0].Maximum)" } elseif ($all.Count) { "Every limit is below $WarningPercent% - the closest: $(@($all)[0].Limit) on $(@($all)[0].Scope) at $(@($all)[0].Percent)%." } else { 'Nothing in scope counts toward a limit.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $critical; Label = 'critical'; Tone = $(if ($critical) { 'bad' } else { 'good' }); Table = 'limits'; Filters = @{ Status = 'Critical' } }
            @{ Value = '{0:N0}' -f $warning; Label = 'close to the limit'; Tone = $(if ($warning) { 'warn' } else { 'good' }); Table = 'limits'; Filters = @{ Status = 'Warning' } }
            @{ Value = '{0:N0}' -f $all.Count; Label = 'limits in use'; Tone = 'info' }
            @{ Value = "$($outcome.Result.Stats.Highest)%"; Label = 'highest use'; Tone = $(if ($critical) { 'bad' } elseif ($warning) { 'warn' } else { 'good' }) }
        )
        Notices  = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'Closest to the limit (%)'; Items = @($all | Select-Object -First 12 | ForEach-Object { @{ Label = "$($_.Limit): $($_.Scope)"; Value = $_.Percent; Tone = $tones[$_.Status] } }); Tone = 'warn'; Console = $true }
            @{ Title = 'By status'; Kind = 'donut'; CenterLabel = 'limits'; Items = @(foreach ($s in 'Critical', 'Warning', 'OK') { $n = @($all | Where-Object Status -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $tones[$s]; Filter = $s } } }); Table = 'limits'; Column = 'Status' }
        )
        Tables   = @(
            @{ Id = 'limits'; Title = 'Azure limits'; Section = 'Limits'; Rows = $limits; Noun = 'limits'; GroupBy = @('ScopeType', 'Limit'); ConsoleLimit = 25
                Empty = 'Nothing in scope counts toward a limit.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $tones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Limit'; Label = 'Limit'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'ScopeType'; Label = 'Scope type'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Scope'; Label = 'Scope'; Console = $true; Pdf = $true }
                    @{ Key = 'Used'; Label = 'Used'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Maximum'; Label = 'Maximum'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Percent'; Label = '%'; Type = 'score'; Format = 'N1'; Console = $true; Pdf = $true }
                    @{ Key = 'Remaining'; Label = 'Left'; Type = 'number' }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide' }
                    @{ Key = 'Advice'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'ScopeId'; Label = 'Scope ID'; Type = 'mono' }
                ) }
        )
        Hint     = '-Status Critical, Warning narrows it; -Maximum sets a raised limit; -FailOnLimit fails a pipeline; -NoDisplay returns the limits; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $limits -Noun 'limit' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $limits -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)

    if ($FailOnLimit) {
        $failing = @($all | Where-Object { $_.Status -eq 'Critical' -or ($FailOnLimit -eq 'Warning' -and $_.Status -eq 'Warning') })
        if ($failing.Count) {
            $problem = [System.InvalidOperationException]::new("Azure Admin Console: $($failing.Count) limit(s) at or above $(if ($FailOnLimit -eq 'Warning') { "$WarningPercent%" } else { "$CriticalPercent%" }): $((@($failing | Select-Object -First 3 | ForEach-Object { "$($_.Limit) on $($_.Scope) ($($_.Used) of $($_.Maximum))" })) -join '; ').")
            $problem.Data['AACHint'] = 'Free up room (see What to do in the report), or ask Azure support to raise the limit and pass it with -Maximum.'
            throw $problem
        }
    }
}