Private/Read-AACResourceMap.ps1
|
function Read-AACResourceMap { <# .SYNOPSIS Reads the resources in some resource groups (or subscriptions), those outside the selection they use and the route next hops, and builds the resource map's model. Shared by Show-AACResourceMap and Export-AACDiagram. .DESCRIPTION Runs inside Invoke-AACProgress (it reports its steps there). Reads Resource Graph: the subscriptions and resource groups, the resources; then, 200 IDs a query, the resources outside the selection that a chosen one refers to; then the owners of route next hops (a firewall or appliance IP) nothing read so far has, in every subscription, so a spoke's routes reach the hub's firewall. Returns @{ Map (ConvertTo-AACResourceMap's result); Title; Facts (the lines under the title) }. #> [CmdletBinding()] [OutputType([hashtable])] param( [string[]] $SubscriptionId = @(), [string[]] $ResourceGroupName = @(), [string[]] $ExcludeType = @(), [string] $Title ) $SubscriptionId = @($SubscriptionId | Where-Object { $_ }) $ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }) $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # Resource Graph queries, several at once (Invoke-AACGraphBatch): rows # as hashtables (tags whose keys differ only by case are fine). $graph = { param([System.Collections.IDictionary] $Query, [string[]] $Subscriptions) (Invoke-AACGraphBatch -Query $Query -SubscriptionId $Subscriptions).Rows } $columns = '| project id, name, type, kind, location, resourceGroup, subscriptionId, sku, properties, tags' $asset = Get-AACResourceMapAsset # --- What to map ------------------------------------------------------------------------ Update-AACProgress -Id 'scope' -Description 'Finding the subscriptions and resource groups' -Indeterminate # The subscriptions, resource groups and resources together. $groupFilter = if ($ResourceGroupName.Count) { " and resourceGroup in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' } $read = & $graph ([ordered]@{ subscriptions = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name" groups = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups'$($groupFilter -replace 'resourceGroup', 'name') | project name, subscriptionId" resources = "resources | where isnotempty(resourceGroup)$groupFilter $columns" }) $SubscriptionId $subscriptionNames = @{} foreach ($row in @($read['subscriptions'])) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } $groups = @($read['groups']) if ($ResourceGroupName.Count) { $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.Count) { " 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 = @($read['resources']) $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) } } } # Those outside it, 200 IDs a query, the queries at once. $external = [System.Collections.Generic.List[object]]::new() $ids = @($referenced) $chunks = [ordered]@{} for ($i = 0; $i -lt $ids.Count; $i += 200) { $chunk = $ids[$i..([Math]::Min($i + 199, $ids.Count - 1))] $chunks["chunk$i"] = "resources | where id in~ ($((@($chunk | ForEach-Object { & $quote $_ })) -join ', ')) $columns" } if ($chunks.Count) { $read = & $graph $chunks @() foreach ($name in @($chunks.Keys)) { foreach ($row in @($read[$name])) { $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 @{ hops = "resources | where type in~ ($types) | where $filter $columns" } @())['hops'])) { 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.Count) { $ResourceGroupName -join ', ' } else { @($SubscriptionId | ForEach-Object { if ($subscriptionNames.Contains($_.ToLowerInvariant())) { $subscriptionNames[$_.ToLowerInvariant()] } else { $_ } }) -join ', ' } $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'))" ) @{ Map = $map; Title = $(if ($Title) { $Title } else { "Resource map: $scopeText" }); Facts = $facts } } |