Public/Invoke-AACServiceBusAssessment.ps1
|
function Invoke-AACServiceBusAssessment { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Assesses Azure Service Bus namespaces across subscriptions - health, capacity, every queue, topic and subscription's messages (active, dead-lettered, scheduled), security, resilience and cost - as a heat map, findings, a Spectre.Console view, objects, and CSV, PDF and interactive HTML reports. .DESCRIPTION Read-only (Reader is enough): Resource Graph the namespaces: tier, messaging units, TLS, local (SAS) authentication, public network access, zone redundancy, private endpoints, encryption Resource Manager each namespace's queues, topics and their subscriptions (message counts and sizes as they are now), network rules, SAS rules, geo-disaster recovery alias, and where its logs go metrics over -TimeRange: IncomingRequests, SuccessfulRequests, ServerErrors, UserErrors, ThrottledRequests, IncomingMessages, OutgoingMessages - and for Premium, NamespaceCpuUsage and NamespaceMemoryUsage Findings (High, Medium, Low): health throttled requests, server errors, many client errors (lost locks, missing entities) capacity Premium CPU or memory close to the messaging units' limit messages dead-lettered and transfer dead-lettered messages, entities filling up (sends fail at 100%), entities not active, active backlogs, topics with no subscription, expired messages dropped instead of dead-lettered security TLS below 1.2, SAS keys allowed, more than one namespace-wide Manage key, open to every network, no logs to Log Analytics resilience Premium not zone redundant, no geo-disaster recovery, Basic tier cost more messaging units than needed, no requests in the period, queues and topics unused for 30 days The heat map scores each namespace 0-100 for health, capacity, messages, security, resilience and cost - the 40 worst at the prompt and in the reports; clicking a cell in the HTML lists its findings. -View sets what is returned (piped onward, -PassThru, -NoDisplay) and written to -CsvPath: Namespace (default), Entity or Finding. .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ManagementGroupId Only the subscriptions under these management groups. .PARAMETER ResourceGroupName Only these resource groups. .PARAMETER Name Only namespaces with these names; wildcards work. .PARAMETER TimeRange How far back the metrics go: 1h, 4h, 12h, 1d (default), 3d, 7d, 14d or 30d. Message counts are always as they are now. .PARAMETER View Namespace, Entity or Finding: what is returned and written to -CsvPath. .PARAMETER ThrottleLimit How many requests run at once (1 to 32; 12 by default). .PARAMETER CsvPath Write the -View rows 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 rows. .PARAMETER NoDisplay Return the rows without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Connect-AAC Invoke-AACServiceBusAssessment Every namespace's health, messages, posture and findings over the last day. .EXAMPLE Invoke-AACServiceBusAssessment -View Entity -NoDisplay | Where-Object DeadLetters | Sort-Object -Property DeadLetters -Descending The queues and subscriptions with dead-lettered messages, the most first. .EXAMPLE Invoke-AACServiceBusAssessment -ResourceGroupName 'rg-integration' -TimeRange 7d -HtmlPath .\out\ServiceBus.html -PdfPath .\out\ServiceBus.pdf A week of a workload's namespaces, as reports. .EXAMPLE Invoke-AACServiceBusAssessment -View Finding -NoDisplay | Where-Object Signal -EQ 'Security' The security findings, to fix. .OUTPUTS AAC.ServiceBusAssessment, AAC.ServiceBusEntity or AAC.ServiceBusFinding, by -View (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.ServiceBusAssessment', 'AAC.ServiceBusEntity', 'AAC.ServiceBusFinding')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ManagementGroupId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [SupportsWildcards()] [string[]] $Name, [ValidateSet('1h', '4h', '12h', '1d', '3d', '7d', '14d', '30d')] [string] $TimeRange = '1d', [ValidateSet('Namespace', 'Entity', 'Finding')] [string] $View = 'Namespace', [ValidateRange(1, 32)] [int] $ThrottleLimit = 12, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Service Bus assessment', [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 $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } # TimeRange -> hours and the metric grain (an interval Azure Monitor accepts). $range = @{ '1h' = @(1, 'PT5M'); '4h' = @(4, 'PT15M'); '12h' = @(12, 'PT30M'); '1d' = @(24, 'PT1H'); '3d' = @(72, 'PT6H'); '7d' = @(168, 'PT6H'); '14d' = @(336, 'PT12H'); '30d' = @(720, 'P1D') }[$TimeRange] $end = [datetime]::UtcNow $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ }) Timespan = "$($end.AddHours(-$range[0]).ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))"; Interval = $range[1]; ThrottleLimit = $ThrottleLimit } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Service Bus assessment' -Color 'darkorange' } $state = 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)" Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the Service Bus namespaces (Resource Graph)' $read = Invoke-AACGraphBatch -Query (Get-AACServiceBusQuery -ResourceGroupName $request.ResourceGroupName) -SubscriptionId $scope.GraphScope -AllowFailure @('workspaces') $namespaces = @($read.Rows['namespaces'] | Where-Object { $n = [string]$_['name']; $null -ne $_ -and (-not $request.Name.Count -or @($request.Name | Where-Object { $n -like $_ }).Count) }) Update-AACProgress -Id 'read' -Complete -Description ('Found {0:N0} namespace(s)' -f $namespaces.Count) $notices = [System.Collections.Generic.List[string]]::new() foreach ($key in @($read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($read.Errors[$key] -replace '\s+', ' ')") } $ids = @($namespaces | ForEach-Object { [string]$_['id'] }) $calls = foreach ($n in $namespaces) { @{ Id = [string]$n['id']; Key = 'Core'; Metric = @('IncomingRequests', 'SuccessfulRequests', 'ServerErrors', 'UserErrors', 'ThrottledRequests', 'IncomingMessages', 'OutgoingMessages'); Aggregation = @('Total'); Filter = '' } if ([string]$n['tier'] -eq 'Premium') { @{ Id = [string]$n['id']; Key = 'Premium'; Metric = @('NamespaceCpuUsage', 'NamespaceMemoryUsage'); Aggregation = @('Maximum'); Filter = '' } } } $calls = @($calls) Update-AACProgress -Id 'entities' -Indeterminate -Description "Reading the queues, topics, subscriptions and settings of $($ids.Count) namespace(s)" $entities = Read-AACServiceBusEntity -NamespaceId $ids -ThrottleLimit $request.ThrottleLimit Update-AACProgress -Id 'entities' -Complete -Description ('Read the entities and settings of {0:N0} namespace(s)' -f $ids.Count) Update-AACProgress -Id 'metrics' -Total ([Math]::Max(1, $calls.Count)) -Description 'Reading the metrics (Azure Monitor)' $metrics = Read-AACNetworkHealthMetric -Call $calls -Timespan $request.Timespan -Interval $request.Interval -ThrottleLimit $request.ThrottleLimit -OnProgress { param($Done, $Total) Update-AACProgress -Id 'metrics' -Increment 1 } Update-AACProgress -Id 'metrics' -Complete -Description ('Read the metrics of {0:N0} namespace(s)' -f $ids.Count) $failedMetrics = @($ids | Where-Object { $m = $metrics[$_]; $m -and $m['Core'] -and $m['Core'].Error }).Count if ($failedMetrics) { $notices.Add("Metrics couldn't be read for $failedMetrics namespace(s) - their health is left blank (see MetricsError).") } $unread = @($ids | Where-Object { $e = $entities[$_]; $e.Queues -and $e.Queues['Error'] -and $e.Topics -and $e.Topics['Error'] }).Count if ($unread) { $notices.Add("The queues and topics of $unread namespace(s) couldn't be read - their messages are left out.") } $workspaceName = @{} foreach ($w in @($read.Rows['workspaces'])) { if ($w) { $workspaceName[[string]$w['id']] = [string]$w['name'] } } $result = ConvertTo-AACServiceBusAssessment -Namespace $namespaces -Entity $entities -Metric $metrics -WorkspaceName $workspaceName -SubscriptionName $scope.Names @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() } } # --- Report ------------------------------------------------------------------------------------------------ $r = $state.Result $stats = $r.Stats $rank = Get-AACSeverityRank $namespaceRows = @($r.Namespaces | Sort-Object -Property @{ Expression = { if ($null -eq $_.Score) { 101 } else { $_.Score } } }, Namespace) $entityRows = @($r.Entities | Sort-Object -Property @{ Expression = { $_.DeadLetters + $_.TransferDeadLetters }; Descending = $true }, @{ Expression = { $_.ActiveMessages }; Descending = $true }, Namespace, Entity) $yesNo = @{ True = 'good'; False = 'warn' } $report = @{ Subtitle = "Service Bus - last $TimeRange" Facts = [ordered]@{ Scope = $state.Scope.Label; 'Time range' = "$TimeRange (messages: as they are now)"; Generated = "$([datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture)) UTC" } Status = $(if ($stats.High) { 'Failed' } elseif (@($r.Findings).Count) { 'Warning' } else { 'Success' }) Headline = $(if (-not $stats.Namespaces) { 'No Service Bus namespace in scope.' } else { "$($stats.Namespaces) namespace(s), $($stats.Entities) queue(s), topic(s) and subscription(s): $($stats.High) high finding(s); $('{0:N0}' -f $stats.DeadLetters) dead-lettered and $('{0:N0}' -f $stats.Active) active message(s); $('{0:N0}' -f $stats.Throttled) throttled request(s); $($stats.Public) open to every network, $($stats.LocalAuth) allowing SAS keys" }) Tiles = @( @{ Value = '{0:N0}' -f $stats.Namespaces; Label = 'namespaces'; Tone = 'info'; Table = 'namespaces' } @{ Value = '{0:N0}' -f $stats.Entities; Label = 'queues, topics, subscriptions'; Tone = 'info'; Table = 'entities' } @{ Value = '{0:N0}' -f $stats.Active; Label = 'active messages'; Tone = 'info'; Table = 'entities' } @{ Value = '{0:N0}' -f $stats.DeadLetters; Label = 'dead-lettered'; Tone = $(if ($stats.DeadLetters) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Finding = 'Dead-lettered messages' } } @{ Value = '{0:N0}' -f $stats.Throttled; Label = 'throttled requests'; Tone = $(if ($stats.Throttled) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Finding = 'Requests throttled' } } @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } } ) Notices = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) HeatMaps = @($(if (@($r.HeatMap.Rows).Count) { $r.HeatMap })) Charts = @( @{ Title = 'Messages waiting'; Kind = 'donut'; CenterLabel = 'messages'; Items = @(foreach ($k in @($r.Messages.Keys)) { if ($r.Messages[$k]) { @{ Label = $k; Value = $r.Messages[$k]; Tone = @{ Active = 'info'; 'Dead-lettered' = 'bad'; Scheduled = 'neutral'; 'Transfer dead-lettered' = 'warn' }[$k] } } }) } @{ Title = 'Tiers'; Items = @($r.Namespaces | Group-Object -Property Tier | Sort-Object -Property Count -Descending | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(unknown)' }); Value = $_.Count; Filter = $_.Name } }); Table = 'namespaces'; Column = 'Tier'; Tone = 'violet' } @{ Title = 'Findings by signal'; Items = @($r.Findings | Group-Object -Property Signal | Sort-Object -Property Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Signal'; Tone = 'warn'; Console = $true } ) Tabs = @( @{ Name = 'Overview'; Badge = $stats.Namespaces; Tone = 'info'; Note = 'Each namespace: requests, errors and throttling over the period, Premium CPU and memory, and its messages now.' } @{ Name = 'Entities'; Badge = $stats.Entities; Tone = $(if ($stats.DeadLetters) { 'bad' } else { 'violet' }); Note = 'Every queue, topic and subscription: active, dead-lettered and scheduled messages, size against its maximum, and when it was last used.' } @{ Name = 'Security & resilience'; Badge = $stats.Public + $stats.LocalAuth; Tone = $(if ($stats.Public) { 'bad' } else { 'good' }); Note = 'Each namespace''s TLS, authentication, network access, encryption, logs, zone redundancy and geo-disaster recovery.' } @{ Name = 'Findings'; Badge = @($r.Findings).Count; Tone = $(if ($stats.High) { 'bad' } else { 'warn' }); Note = 'What to fix, worst first.' } ) Tables = @( @{ Id = 'namespaces'; Title = 'Overview'; Section = 'Overview'; Rows = $namespaceRows; Noun = 'namespaces'; GroupBy = @('Subscription', 'ResourceGroup', 'Tier'); ConsoleLimit = 15 Columns = @( @{ Key = 'Namespace'; Label = 'Namespace'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Score'; Label = 'Score'; Type = 'score'; Format = 'N0'; Console = $true; Pdf = $true } @{ Key = 'Tier'; Label = 'Tier'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Requests'; Label = 'Requests'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'ErrorRate'; Label = 'Error %'; Type = 'number'; Format = 'N2'; Console = $true; Pdf = $true } @{ Key = 'Throttled'; Label = 'Throttled'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'ActiveMessages'; Label = 'Active'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'DeadLetters'; Label = 'Dead-lettered'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'FullestPct'; Label = 'Fullest entity %'; Type = 'number'; Format = 'N1'; Console = $true } @{ Key = 'CpuMax'; Label = 'CPU max %'; Type = 'number'; Format = 'N1' } @{ Key = 'MemoryMax'; Label = 'Memory max %'; Type = 'number'; Format = 'N1' } @{ Key = 'MessagingUnits'; Label = 'Messaging units'; Type = 'number' } @{ Key = 'Queues'; Label = 'Queues'; Type = 'number'; Sum = $true } @{ Key = 'Topics'; Label = 'Topics'; Type = 'number'; Sum = $true } @{ Key = 'Subscriptions'; Label = 'Subscriptions'; Type = 'number'; Sum = $true } @{ Key = 'IncomingMessages'; Label = 'Messages in'; Type = 'number'; Sum = $true } @{ Key = 'OutgoingMessages'; Label = 'Messages out'; Type = 'number'; Sum = $true } @{ Key = 'ServerErrors'; Label = 'Server errors'; Type = 'number'; Sum = $true } @{ Key = 'UserErrors'; Label = 'Client errors'; Type = 'number'; Sum = $true } @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Sum = $true; Console = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'entities'; Title = 'Entities'; Section = 'Entities'; Rows = $entityRows; Noun = 'entities'; GroupBy = @('Namespace', 'Type', 'Status'); ConsoleLimit = 20 Empty = 'No queue, topic or subscription.'; EmptyStatus = 'Info' Columns = @( @{ Key = 'Namespace'; Label = 'Namespace'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Entity'; Label = 'Entity'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Type'; Label = 'Type'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = @{ Active = 'good'; Disabled = 'bad'; SendDisabled = 'warn'; ReceiveDisabled = 'warn' }; Facet = $true; Console = $true } @{ Key = 'ActiveMessages'; Label = 'Active'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'DeadLetters'; Label = 'Dead-lettered'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'TransferDeadLetters'; Label = 'Transfer DLQ'; Type = 'number'; Sum = $true } @{ Key = 'Scheduled'; Label = 'Scheduled'; Type = 'number'; Sum = $true } @{ Key = 'UsedPct'; Label = 'Used %'; Type = 'number'; Format = 'N1'; Console = $true; Pdf = $true } @{ Key = 'SizeMB'; Label = 'Size MB'; Type = 'number'; Format = 'N2' } @{ Key = 'MaxSizeMB'; Label = 'Max MB'; Type = 'number' } @{ Key = 'Subscriptions'; Label = 'Subscriptions'; Type = 'number' } @{ Key = 'MaxDeliveryCount'; Label = 'Max deliveries'; Type = 'number' } @{ Key = 'LockDuration'; Label = 'Lock' } @{ Key = 'Sessions'; Label = 'Sessions'; Facet = $true } @{ Key = 'DeadLetterOnExpiration'; Label = 'DLQ on expiry'; Type = 'badge'; Tones = $yesNo; Facet = $true } @{ Key = 'ForwardTo'; Label = 'Forwards to' } @{ Key = 'IdleDays'; Label = 'Idle days'; Type = 'number'; Console = $true } @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Sum = $true } ) } @{ Id = 'posture'; Title = 'Security & resilience'; Section = 'Security & resilience'; Rows = $namespaceRows; Noun = 'namespaces'; GroupBy = @('Network', 'Tier', 'LocalAuth'); ConsoleLimit = 15 Columns = @( @{ Key = 'Namespace'; Label = 'Namespace'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Network'; Label = 'Network'; Type = 'badge'; Tones = @{ Private = 'good'; Firewall = 'info'; Public = 'warn' }; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'MinTls'; Label = 'Min TLS'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'LocalAuth'; Label = 'SAS keys'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'ManageKeys'; Label = 'Manage keys'; Type = 'number'; Console = $true } @{ Key = 'PrivateEndpoints'; Label = 'Private endpoints'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'ZoneRedundant'; Label = 'Zone redundant'; Type = 'badge'; Tones = $yesNo; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'GeoDr'; Label = 'Geo-DR'; Facet = $true; Console = $true } @{ Key = 'LogsTo'; Label = 'Logs to'; Facet = $true; Console = $true } @{ Key = 'Encryption'; Label = 'Encryption'; Facet = $true } @{ Key = 'Identity'; Label = 'Identity'; Facet = $true } @{ Key = 'Tier'; Label = 'Tier'; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = @($r.Findings); Noun = 'findings'; GroupBy = @('Signal', 'Finding', 'Severity', 'Namespace'); ConsoleLimit = 20 Empty = 'Nothing to fix.'; EmptyStatus = 'Success' Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Namespace'; Label = 'Namespace'; Type = 'resource'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Entity'; Label = 'Entity'; Console = $true; Pdf = $true } @{ Key = 'Signal'; Label = 'Signal'; Facet = $true; Console = $true } @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true } @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide'; Pdf = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } ) Hint = '-View Entity returns every queue, topic and subscription (Where-Object DeadLetters finds the dead letters); -TimeRange 7d looks further back; -Name or -ResourceGroupName narrows it; -HtmlPath, -PdfPath or -CsvPath for a report.' } $rows = @(switch ($View) { 'Entity' { $entityRows } 'Finding' { $r.Findings } default { $namespaceRows } }) Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun $View.ToLowerInvariant() -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |