Public/Show-AACResourceMap.ps1
|
function Show-AACResourceMap { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Draws a map of the resources in one or more resource groups - with their connections, dependencies and network paths - and opens it in your browser, ready to save as a PNG or JPEG image. .DESCRIPTION Reads the resources with Azure Resource Graph (Reader access is enough; no Az modules) and draws them with their official Azure icons, laid out by the Eclipse Layout Kernel (ELK), in boxes: subscription > resource group > virtual network > subnet A network interface, private endpoint, firewall, gateway, Bastion, internal load balancer, AKS cluster or API Management service is drawn in the subnet it has an IP in, and a virtual machine in the subnet of its first network interface. Each resource shows its name, its product, and a detail worth seeing: a VM's size, an IP address, a disk's size. The lines between them come from the resource IDs in each resource's properties, typed by what they are: Network association NICs, IPs, subnets, NSGs, route tables, NAT, gateways, VNet integration Resource dependency an app on its plan, a VM on its disks, a database on its server, a diagnostic target VNet peering with its state Private link a private endpoint to the resource it serves Route (next hop) a route table to the firewall or appliance its routes send traffic to Private DNS link a private DNS zone linked to a network NSGs and route tables are drawn the way a network engineer reads them: as chips on the subnets and NICs they are applied to (-NsgView Cards draws them as resources with lines instead). Click one for its rules or routes. A chip is red when it opens a management or database port (SSH, RDP, WinRM, SMB, SQL, ...) or every port to the internet, or when a route's next hop is an IP no resource you can see has; amber for a wide port range open to the internet, or 0.0.0.0/0 straight to the internet. A subnet's routes are drawn from the subnet ("0.0.0.0/0 via rt-spoke") to the firewall or appliance they go through - looked up across every subscription you can see, so a spoke's map reaches the hub's firewall. Each subnet's box also shows how many of its addresses are used and what it's delegated to, and each peering what it lets through (forwarded traffic, gateway transit, remote gateway). A resource outside the chosen resource groups that a chosen one uses - a VNet in the hub's resource group, say - is drawn too, in its own resource group's box, marked as outside the selection. Resources attached to nothing (a network interface without a VM, a public IP without a configuration, an unattached disk, an NSG or route table on nothing) are flagged. The map is one self-contained HTML file, written to -HtmlPath (a file in your temp folder by default) and opened in your default browser. In the browser you can: - pan and zoom, and fit the map to the window - click a resource or box: its connections light up and the rest fades, with its details, connections and an Azure portal link - find a resource by name, type, IP or resource group - switch between left to right and top to bottom, and the Dark, Light and Blueprint themes - show or hide each kind of connection - save the map as a PNG or JPEG image (or SVG), as drawn The Azure icons are Microsoft's Azure architecture icons, used under Microsoft's terms for architecture diagrams (lib\azure-icons\SOURCES.md). .PARAMETER SubscriptionId The subscriptions to map. With -ResourceGroupName, the resource groups are looked for in these subscriptions; alone, every resource group in them is mapped. .PARAMETER ResourceGroupName The resource groups to map. Without -SubscriptionId they are looked for in every subscription you can see. .PARAMETER Direction How the map flows: LeftToRight (the default) or TopToBottom. The page can switch it too. .PARAMETER Theme Dark (the default), Light or Blueprint. The page can switch it too. .PARAMETER NsgView Chips (the default): NSGs and route tables as chips on the subnets and NICs they're applied to. Cards: as resources, with lines to them. The page can switch it too. .PARAMETER ExcludeType Resource types to leave out, e.g. 'microsoft.insights/*' for alerts and action groups; wildcards work. .PARAMETER HtmlPath Where to write the map. By default a file in your temp folder. .PARAMETER Title The map's title. By default, the resource groups (or subscriptions). .PARAMETER NoBrowser Write the map without opening it. .PARAMETER PassThru Also return the map: its boxes, resources and connections, and the file's path. .EXAMPLE Connect-AAC Show-AACResourceMap -SubscriptionId '00000000-0000-0000-0000-000000000000' -ResourceGroupName 'rg-app' One resource group's map, opened in the browser. .EXAMPLE Show-AACResourceMap -SubscriptionId $hub, $spoke -ResourceGroupName 'rg-hub', 'rg-spoke-app', 'rg-spoke-data' -Direction TopToBottom A hub-and-spoke network across two subscriptions, top to bottom. .EXAMPLE Show-AACResourceMap -ResourceGroupName 'rg-app' -Theme Light -HtmlPath .\out\rg-app-map.html -NoBrowser A light-themed map written to a file, not opened. .EXAMPLE (Show-AACResourceMap -ResourceGroupName 'rg-app' -NoBrowser -PassThru).Nodes | Where-Object Orphan The resources attached to nothing. .OUTPUTS AAC.ResourceMap, with -PassThru #> [CmdletBinding()] [OutputType('AAC.ResourceMap')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [ValidateSet('LeftToRight', 'TopToBottom')] [string] $Direction = 'LeftToRight', [ValidateSet('Dark', 'Light', 'Blueprint')] [string] $Theme = 'Dark', [ValidateSet('Chips', 'Cards')] [string] $NsgView = 'Chips', [SupportsWildcards()] [string[]] $ExcludeType, [string] $HtmlPath, [string] $Title, [switch] $NoBrowser, [switch] $PassThru ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. trap { $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } if (-not $SubscriptionId -and -not $ResourceGroupName) { throw 'Say what to map: -SubscriptionId, -ResourceGroupName, or both.' } $htmlFullPath = if ($HtmlPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($HtmlPath) } else { Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('AAC-ResourceMap-{0:yyyyMMdd-HHmmss}.html' -f (Get-Date)) } $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # Resource Graph through ARM: rows as hashtables (tags whose keys differ # only by case are fine), following $skipToken. $graph = { param([string] $Query, [string[]] $Subscriptions) $body = @{ query = $Query; options = @{ resultFormat = 'objectArray' } } if ($Subscriptions) { $body.subscriptions = @($Subscriptions) } do { $response = Invoke-AACArmRequest -Method Post -Uri '/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' -Body ($body | ConvertTo-Json -Depth 10) $response['data'] $skipToken = $response['$skipToken'] $body.options['$skipToken'] = $skipToken } while ($skipToken) } $columns = '| project id, name, type, kind, location, resourceGroup, subscriptionId, sku, properties, tags' Write-AACRule -Title 'Azure Admin Console :: Resource map' -Color 'deepskyblue3_1' $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken $asset = Get-AACResourceMapAsset # --- What to map ------------------------------------------------------------------------ Update-AACProgress -Id 'scope' -Description 'Finding the subscriptions and resource groups' -Indeterminate $subscriptionNames = @{} foreach ($row in @(& $graph "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name" $SubscriptionId)) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } $groupFilter = if ($ResourceGroupName) { " and resourceGroup in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' } $groups = @(& $graph "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups'$($groupFilter -replace 'resourceGroup', 'name') | project name, subscriptionId" $SubscriptionId) if ($ResourceGroupName) { $missing = @($ResourceGroupName | Where-Object { $name = $_; -not @($groups | Where-Object { [string]$_['name'] -eq $name }).Count }) if ($missing.Count -eq $ResourceGroupName.Count) { throw "No resource group named $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found$(if ($SubscriptionId) { " in subscription $($SubscriptionId -join ', ')" } else { ' in any subscription you can see' })." } foreach ($name in $missing) { Write-Warning "No resource group named '$name' was found; the map leaves it out." } } Update-AACProgress -Id 'scope' -Complete -Description ('Found {0} resource group(s) in {1} subscription(s)' -f $groups.Count, @($groups | ForEach-Object { $_['subscriptionId'] } | Sort-Object -Unique).Count) # --- The resources, and those outside the selection they use ---------------------------- Update-AACProgress -Id 'read' -Description 'Reading the resources from Azure Resource Graph' -Indeterminate $resources = @(& $graph "resources | where isnotempty(resourceGroup)$groupFilter $columns" $SubscriptionId) $known = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in $resources) { [void]$known.Add([string]$row['id']) } $referenced = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in $resources) { $text = ConvertTo-Json -InputObject $row['properties'] -Depth 30 -Compress foreach ($match in [regex]::Matches($text, '(?i)/subscriptions/[^/"]+/resourcegroups/[^/"]+/providers/[^/"]+/[^/"]+/[^/"]+')) { if (-not $known.Contains($match.Value)) { [void]$referenced.Add($match.Value) } } } $external = [System.Collections.Generic.List[object]]::new() $ids = @($referenced) for ($i = 0; $i -lt $ids.Count; $i += 200) { $chunk = $ids[$i..([Math]::Min($i + 199, $ids.Count - 1))] foreach ($row in @(& $graph "resources | where id in~ ($((@($chunk | ForEach-Object { & $quote $_ })) -join ', ')) $columns" @())) { $external.Add($row) } } # Route next hops (a firewall or appliance IP) that nothing read so far # has: look for their owner in every subscription, so a spoke's routes # reach the hub's firewall. $owned = [System.Collections.Generic.HashSet[string]]::new() foreach ($row in @($resources) + @($external)) { foreach ($match in [regex]::Matches((ConvertTo-Json -InputObject $row['properties'] -Depth 30 -Compress), '"privateIPAddress":"([^"]+)"')) { [void]$owned.Add($match.Groups[1].Value) } } $hops = @(foreach ($row in $resources) { if ([string]$row['type'] -ne 'microsoft.network/routetables' -and [string]$row['type'] -ne 'Microsoft.Network/routeTables') { continue } foreach ($match in [regex]::Matches((ConvertTo-Json -InputObject $row['properties'] -Depth 30 -Compress), '"nextHopIpAddress":"([^"]+)"')) { $match.Groups[1].Value } }) | Where-Object { $_ -and -not $owned.Contains($_) } | Select-Object -Unique if ($hops) { $types = "'microsoft.network/azurefirewalls', 'microsoft.network/networkinterfaces', 'microsoft.network/loadbalancers', 'microsoft.network/applicationgateways'" $filter = (@($hops | ForEach-Object { "properties contains $(& $quote $_)" })) -join ' or ' foreach ($row in @(& $graph "resources | where type in~ ($types) | where $filter $columns" @())) { if (-not $known.Contains([string]$row['id']) -and -not @($external | Where-Object { [string]$_['id'] -eq [string]$row['id'] }).Count) { $external.Add($row) } } } Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} resource(s), and {1:N0} outside the selection they use' -f $resources.Count, $external.Count) # --- The map ------------------------------------------------------------------------------ Update-AACProgress -Id 'map' -Description 'Working out the connections and network paths' -Indeterminate $labels = @{} foreach ($key in $asset.Icons.icons.Keys) { $labels[$key] = [string]$asset.Icons.icons[$key].label } $map = ConvertTo-AACResourceMap -Resource $resources -External $external.ToArray() -SubscriptionName $subscriptionNames -IconType $asset.Icons.types -IconLabel $labels -ExcludeType @($ExcludeType | Where-Object { $_ }) Update-AACProgress -Id 'map' -Complete -Description ('Mapped {0:N0} resource(s) and {1:N0} connection(s)' -f $map.Stats.Resources, $map.Stats.Connections) $scopeText = if ($ResourceGroupName) { $ResourceGroupName -join ', ' } else { @($SubscriptionId | ForEach-Object { if ($subscriptionNames.Contains($_.ToLowerInvariant())) { $subscriptionNames[$_.ToLowerInvariant()] } else { $_ } }) -join ', ' } $mapTitle = if ($Title) { $Title } else { "Resource map: $scopeText" } $facts = @( '{0:N0} resources' -f $map.Stats.Resources '{0:N0} resource group(s)' -f $map.Stats.Groups '{0:N0} virtual network(s)' -f $map.Stats.Networks '{0:N0} connections' -f $map.Stats.Connections if ($map.Stats.Outside) { '{0:N0} outside the selection' -f $map.Stats.Outside } if ($map.Stats.Orphans) { '{0:N0} unattached' -f $map.Stats.Orphans } if ($map.Stats.Flagged) { '{0:N0} NSG / route table(s) flagged' -f $map.Stats.Flagged } "drawn $((Get-Date).ToString('d MMM yyyy HH:mm'))" ) Update-AACProgress -Id 'html' -Description 'Writing the map' -Indeterminate Write-AACResourceMapHtml -Map $map -Path $htmlFullPath -Title $mapTitle -Fact $facts -Direction $(if ($Direction -eq 'TopToBottom') { 'DOWN' } else { 'RIGHT' }) -Theme $Theme.ToLowerInvariant() -NsgView $NsgView.ToLowerInvariant() Update-AACProgress -Id 'html' -Complete -Description "Map: $htmlFullPath" @{ Map = $map; Title = $mapTitle } } $map = $state.Map if ($map.Stats.Resources -eq 0) { Write-AACMarkup '[grey58]No resources were found in that selection; the map is empty.[/]' } else { $glyph = Get-AACGlyph $parts = @("[white]$($map.Stats.Resources)[/] resources", "[white]$($map.Stats.Connections)[/] connections") if ($map.Stats.Outside) { $parts += "[white]$($map.Stats.Outside)[/] outside the selection" } if ($map.Stats.Orphans) { $parts += "[orange1]$($map.Stats.Orphans) unattached[/]" } if ($map.Stats.Flagged) { $parts += "[red1]$($map.Stats.Flagged) NSG / route table(s) flagged[/]" } Write-AACMarkup "[grey58]$($parts -join " $($glyph.Dot) ")[/]" } if (-not $NoBrowser) { Open-AACFile -Path $htmlFullPath Write-AACMarkup '[grey58]Opened in your browser. Save PNG and Save JPEG are at the top of the page.[/]' } if ($PassThru) { [pscustomobject]@{ PSTypeName = 'AAC.ResourceMap' Title = $state.Title Path = $htmlFullPath Clusters = @($map.Clusters | ForEach-Object { [pscustomobject]$_ }) Nodes = @($map.Nodes | ForEach-Object { [pscustomobject]@{ Name = $_.name; Type = $_.type; Product = $_.typeLabel; ResourceGroup = $_.resourceGroup; Subscription = $_.subscription Location = $_.location; Parent = $_.parent; Details = ($_.facts -join ' · '); Orphan = $_.orphan; Outside = $_.external; Id = $_.id Risk = $_.risk; AppliedTo = @($_.appliedTo); Rules = @($_.rules | ForEach-Object { [pscustomobject]$_ }); Routes = @($_.routes | ForEach-Object { [pscustomobject]$_ }) } }) Edges = @($map.Edges | ForEach-Object { [pscustomobject]@{ Kind = $_.kind; Source = $_.source; Target = $_.target; Label = $_.label } }) Stats = [pscustomobject]$map.Stats } } } |