Public/Get-AACDependencyGraph.ps1
|
function Get-AACDependencyGraph { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Maps which Azure resources depend on which - networks, gateways and Front Door to their backends, apps to their plans and data, private endpoints, managed identities, and (from Application Insights) who calls whom - and finds each one's blast radius, the single points of failure, circular dependencies and the most critical services. .DESCRIPTION Builds the graph from one Azure Resource Graph batch: VMs on their disks and networks, load balancers and Application Gateways on their backend pools, Front Door on its origins, apps on their App Service plans and integration networks, SQL databases on their servers, private endpoints on their targets, and every resource whose managed identity has a role on another (the data it reads, the registry it pulls from). -IncludeTelemetry adds Application Insights' recorded dependencies (the last -TelemetryHours): which app calls which database, API or external service, how often, and how many calls failed. For each resource (AAC.DependencyNode): what it depends on, what depends on it, its blast radius (every resource that depends on it, directly or through others), whether it's redundant (zones, instances, SKU), and whether it's a single point of failure. Findings: single points of failure (High with a blast radius of 3 or more), circular dependencies, and the five most critical services - with the resilience fix for each: redundancy, failover, circuit breakers. -DotPath writes the graph in Graphviz DOT (dot -Tsvg graph.dot -o graph.svg); single points of failure are red. Read-only; Reader is enough (and Log Analytics Reader for -IncludeTelemetry). .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER ResourceGroupName Only the resources in these resource groups (and what they depend on). .PARAMETER IncludeTelemetry Add the dependencies Application Insights recorded (workspace-based components). .PARAMETER TelemetryHours How far back the telemetry goes (1 to 720 hours; 24 by default). .PARAMETER DotPath Write the graph in Graphviz DOT to this file. .PARAMETER CsvPath Write the resources (nodes) to this CSV file. .PARAMETER HtmlPath Write an interactive HTML report: findings, resources and every dependency. .PARAMETER PdfPath Write a PDF report. .PARAMETER Title The reports' title. .PARAMETER PassThru Show the view and also return the resources. .PARAMETER NoDisplay Return the resources without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Get-AACDependencyGraph -ResourceGroupName 'rg-shop-prod' -IncludeTelemetry The shop's dependencies, with who calls whom. .EXAMPLE Get-AACDependencyGraph -DotPath .\out\deps.dot; dot -Tsvg .\out\deps.dot -o .\out\deps.svg The graph as a picture (with Graphviz). .EXAMPLE Get-AACDependencyGraph -NoDisplay | Where-Object SinglePointOfFailure -EQ 'Yes' | Sort-Object BlastRadius -Descending The single points of failure, the widest first. .OUTPUTS AAC.DependencyNode #> [CmdletBinding()] [OutputType('AAC.DependencyNode')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [string[]] $ManagementGroupId, [string[]] $ResourceGroupName, [switch] $IncludeTelemetry, [ValidateRange(1, 720)] [int] $TelemetryHours = 24, [string] $DotPath, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Azure dependency graph', [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) } } $dotFile = & $resolve $DotPath $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); IncludeTelemetry = [bool]$IncludeTelemetry; TelemetryHours = $TelemetryHours } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Dependency graph' -Color 'deepskyblue3_1' } $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)" $queries = Get-AACDependencyQuery -ResourceGroupName $request.ResourceGroupName Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading the resources and what links them' $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('roles', 'insights', 'workspaces', 'origins', 'profiles', 'identities') -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total)" } $notices = [System.Collections.Generic.List[string]]::new() foreach ($key in $read.Errors.Keys) { if ($read.Errors[$key]) { $notices.Add("The $key couldn't be read: $($read.Errors[$key]) - dependencies through them are missing.") } } Update-AACProgress -Id 'read' -Complete -Description "Read the resources ($(@($queries.Keys).Count) queries)" # Application Insights: the dependencies each workspace recorded. $telemetry = [System.Collections.Generic.List[object]]::new() if ($request.IncludeTelemetry) { $customerIds = @{} foreach ($w in @($read.Rows['workspaces'] | Where-Object { $_ })) { $customerIds[([string]$w['id']).ToLowerInvariant()] = [string]$w['customerId'] } $workspaces = @(@($read.Rows['insights'] | Where-Object { $_ }) | ForEach-Object { ([string]$_['workspace']).ToLowerInvariant() } | Where-Object { $_ -and $customerIds.Contains($_) } | Select-Object -Unique) if (-not $workspaces.Count) { $notices.Add('No workspace-based Application Insights component in scope: no telemetry dependencies.') } Update-AACProgress -Id 'telemetry' -Total ([Math]::Max(1, $workspaces.Count)) -Description "Reading the dependencies Application Insights recorded in $($workspaces.Count) workspace(s)" foreach ($w in $workspaces) { $logs = Invoke-AACLogQueryBatch -WorkspaceId $customerIds[$w] -Query @{ dependencies = "AppDependencies | where TimeGenerated > ago($($request.TelemetryHours)h) | summarize Calls = sum(ItemCount), Failed = sumif(ItemCount, Success == false) by AppRoleName, Target, DependencyType | top 500 by Calls" } if ($logs.Errors['dependencies']) { $notices.Add("The telemetry of workspace $($w -replace '^.*/', '') couldn't be read: $($logs.Errors['dependencies'])") } foreach ($row in @($logs.Rows['dependencies'])) { if ($row) { $telemetry.Add($row) } } Update-AACProgress -Id 'telemetry' -Increment 1 } Update-AACProgress -Id 'telemetry' -Complete -Description ('Read {0:N0} recorded dependenc(ies)' -f $telemetry.Count) } Update-AACProgress -Id 'graph' -Indeterminate -Description 'Building the graph: blast radius, single points of failure, cycles' $result = ConvertTo-AACDependencyGraph -Read $read -Telemetry $telemetry.ToArray() -SubscriptionName $scope.Names Update-AACProgress -Id 'graph' -Complete -Description ('{0:N0} resource(s), {1:N0} dependenc(ies): {2:N0} single point(s) of failure, {3:N0} cycle(s)' -f $result.Stats.Nodes, $result.Stats.Edges, $result.Stats.Spof, $result.Stats.Cycles) @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() } } $result = $state.Result $nodes = @($result.Nodes) $s = $result.Stats if ($dotFile) { $quote = { param([string] $Text) '"' + ($Text -replace '\\', '\\' -replace '"', '\"') + '"' } $spofIds = @($nodes | Where-Object SinglePointOfFailure -EQ 'Yes' | ForEach-Object { if ($_.ResourceId) { $_.ResourceId } else { "external:$($_.Resource)" } }) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add('digraph AzureDependencies {') $lines.Add(' rankdir=LR; node [shape=box, style="rounded,filled", fillcolor="#EEF2FF", fontname="Segoe UI"]; edge [fontname="Segoe UI", fontsize=9];') foreach ($n in $nodes) { $id = if ($n.ResourceId) { $n.ResourceId } else { "external:$($n.Resource)" } $fill = if ($spofIds -contains $id) { '#FEE2E2' } elseif ($n.Type -eq 'external') { '#F3F4F6' } else { '#EEF2FF' } $lines.Add(" $(& $quote $id) [label=$(& $quote "$($n.Resource)`n$($n.Type -replace '^microsoft\.', '')"), fillcolor=$(& $quote $fill)];") } foreach ($e in $result.Edges) { $lines.Add(" $(& $quote $e.FromId) -> $(& $quote $e.ToId) [label=$(& $quote $e.Relation)$(if ($e.Relation -eq 'peered') { ', dir=both, style=dashed' })];") } $lines.Add('}') $folder = Split-Path -Path $dotFile -Parent if ($folder -and -not (Test-Path -LiteralPath $folder)) { $null = New-Item -ItemType Directory -Path $folder -Force } [System.IO.File]::WriteAllLines($dotFile, $lines, [System.Text.UTF8Encoding]::new($false)) if ($interactive) { Write-AACStatusLine Success "Graphviz DOT: $dotFile" -Detail 'dot -Tsvg graph.dot -o graph.svg' } } $rank = Get-AACSeverityRank $report = @{ Subtitle = 'Dependency graph: blast radius, single points of failure, circular dependencies' Facts = [ordered]@{ Scope = $state.Scope.Label; 'Resource groups' = $(if ($ResourceGroupName) { $ResourceGroupName -join ', ' } else { 'all' }); Telemetry = $(if ($IncludeTelemetry) { "Application Insights, the last $TelemetryHours h" } else { 'not read (-IncludeTelemetry)' }) } Status = $(if (@($result.Findings | Where-Object Severity -EQ 'High').Count) { 'Failed' } elseif ($s.Spof -or $s.Cycles) { 'Warning' } else { 'Success' }) Headline = $(if ($s.Nodes) { "$($s.Nodes) resource(s), $($s.Edges) dependenc(ies): $($s.Spof) single point(s) of failure, $($s.Cycles) circular dependenc(ies) - the widest blast radius is $($s.MaxRadius)" } else { 'No dependencies found in scope.' }) Tiles = @( @{ Value = '{0:N0}' -f $s.Nodes; Label = 'resources'; Tone = 'info'; Table = 'nodes' } @{ Value = '{0:N0}' -f $s.Edges; Label = 'dependencies'; Tone = 'violet'; Table = 'edges' } @{ Value = '{0:N0}' -f $s.Spof; Label = 'single points of failure'; Tone = $(if ($s.Spof) { 'bad' } else { 'good' }); Table = 'nodes'; Filters = @{ SinglePointOfFailure = 'Yes' } } @{ Value = '{0:N0}' -f $s.Cycles; Label = 'circular dependencies'; Tone = $(if ($s.Cycles) { 'warn' } else { 'good' }) } @{ Value = '{0:N0}' -f $s.MaxRadius; Label = 'widest blast radius'; Tone = 'warn' } @{ Value = '{0:N0}' -f $s.External; Label = 'external dependencies'; Tone = 'neutral' } ) Notices = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) Charts = @( @{ Title = 'Widest blast radius'; Items = @($nodes | Where-Object { $_.BlastRadius -gt 0 } | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Resource; Value = $_.BlastRadius; Filter = $_.Resource; Tone = $(if ($_.SinglePointOfFailure -eq 'Yes') { 'bad' } else { 'info' }) } }); Table = 'nodes'; Column = 'Resource'; Console = $true } @{ Title = 'Dependencies by kind'; Kind = 'donut'; CenterLabel = 'dependencies'; Items = @($result.Edges | Group-Object Relation | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'edges'; Column = 'Relation' } ) Tables = @( @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = $result.Findings; Noun = 'findings'; ConsoleLimit = 20 Empty = 'No single points of failure or circular dependencies found.' Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Category'; Label = 'Kind'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Finding'; Label = 'Finding'; Type = 'wide'; Console = $true; Pdf = $true } @{ Key = 'BlastRadius'; Label = 'Blast radius'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide' } @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true } @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' } ) } @{ Id = 'nodes'; Title = 'Resources'; Section = 'Resources'; Rows = $nodes; Noun = 'resources'; ConsoleLimit = 15; GroupBy = @('Type', 'ResourceGroup', 'SinglePointOfFailure') Columns = @( @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Type'; Label = 'Type'; Type = 'mono'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'BlastRadius'; Label = 'Blast radius'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'Dependents'; Label = 'Dependents'; Type = 'number'; Console = $true } @{ Key = 'DependsOn'; Label = 'Depends on'; Type = 'number'; Console = $true } @{ Key = 'Redundant'; Label = 'Redundant'; Type = 'badge'; Tones = @{ Yes = 'good'; No = 'warn' }; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'SinglePointOfFailure'; Label = 'Single point of failure'; Type = 'badge'; Tones = @{ Yes = 'bad'; No = 'neutral' }; Facet = $true; Pdf = $true } @{ Key = 'DependentsList'; Label = 'Depended on by'; Type = 'wide' } @{ Key = 'DependsOnList'; Label = 'Depends on (names)'; Type = 'wide' } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'edges'; Title = 'Dependencies'; Section = 'Dependencies'; Rows = $result.Edges; Noun = 'dependencies'; NoConsole = $true; GroupBy = @('From', 'Relation', 'To') Columns = @(@{ Key = 'From'; Label = 'Resource' }, @{ Key = 'Relation'; Label = 'Relation'; Facet = $true }, @{ Key = 'To'; Label = 'Depends on' }, @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide' }) } ) Hint = '-IncludeTelemetry adds who calls whom; -DotPath writes the graph for Graphviz; -NoDisplay returns the resources.' } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $nodes -Noun 'resource' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $nodes -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |