Private/Invoke-VmPowerGraphQuery.ps1
|
function Invoke-VmPowerGraphQuery { <# .SYNOPSIS Run one Resource Graph query and return every page of it. .DESCRIPTION Calls the Resource Graph REST API through Invoke-AzRestMethod rather than Search-AzGraph, which means Az.ResourceGraph is not a dependency. That matters in Azure Automation: the PowerShell 7.2 runtime ships a fixed Az bundle, and importing Az modules alongside it has broken assembly loading in the sandbox before. Az.Accounts is already required for signing in, and it carries Invoke-AzRestMethod, so this costs nothing. Omitting the subscription list is deliberate. Resource Graph then answers for every subscription the signed-in identity can read - verified on 2026-09-10 against a tenant with two - which is what makes discovery one call rather than a loop with a context switch in it. Paging is not optional. Resource Graph returns at most 1000 rows per page, and a plan that silently stops at the first page is worse than no plan. .PARAMETER Query The KQL query. .PARAMETER SubscriptionId Limit the query to these subscriptions. Omit for every subscription the identity can read. .PARAMETER PageSize Rows per page. The service caps this at 1000. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string]$Query, [Parameter()] [string[]]$SubscriptionId, [Parameter()] [ValidateRange(1, 1000)] [int]$PageSize = 1000 ) $uri = 'https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' $rows = [System.Collections.Generic.List[object]]::new() $skipToken = $null $page = 0 do { $body = [ordered]@{ query = $Query; options = [ordered]@{ '$top' = $PageSize } } if ($SubscriptionId) { $body['subscriptions'] = @($SubscriptionId) } if ($skipToken) { $body.options['$skipToken'] = $skipToken } $response = Invoke-AzRestMethod -Uri $uri -Method POST -Payload ($body | ConvertTo-Json -Depth 5 -Compress) -ErrorAction Stop if ($response.StatusCode -ne 200) { throw "Resource Graph returned $($response.StatusCode): $($response.Content)" } $result = $response.Content | ConvertFrom-Json foreach ($row in @($result.data)) { $rows.Add($row) } $skipToken = if ($result.PSObject.Properties.Name -contains '$skipToken') { $result.'$skipToken' } else { $null } $page++ Write-Verbose "Resource Graph page $page returned $(@($result.data).Count) row(s); total so far $($rows.Count) of $($result.totalRecords)." } while ($skipToken) # No comma wrapper. ", $rows.ToArray()" emits the array as a single pipeline item, which @() # at the call site then collects as one element: zero rows become one phantom machine, and n # rows become one "machine" that is really the whole list. Emitting the rows and letting the # caller wrap in @() is correct for none, one and many. $rows.ToArray() } |