Private/New-AzureUtilsIdleResourceQuery.ps1

function New-AzureUtilsIdleResourceQuery {
    <#
        .SYNOPSIS
            Builds the Resource Graph (KQL) query for idle (running but unused) resources.
        .DESCRIPTION
            Pure, side-effect-free. Returns a union of the selected idle categories,
            each projecting the same columns plus a 'reason' explaining the finding.
            Unlike orphaned resources (which are unassociated), these resources exist
            and may still incur cost while doing no useful work.
        .PARAMETER Type
            Which idle categories to include. Defaults to all.
        .OUTPUTS
            System.String (the KQL query)
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [ValidateSet('StoppedVm', 'EmptyAppServicePlan', 'StoppedAksCluster')]
        [string[]] $Type = @('StoppedVm', 'EmptyAppServicePlan', 'StoppedAksCluster')
    )

    $cols = 'id, name, type, resourceGroup, location, subscriptionId, tags, reason'
    $subQueries = [System.Collections.Generic.List[string]]::new()

    if ($Type -contains 'StoppedVm') {
        # A VM that is not running is idle. 'PowerState/stopped' is still allocated
        # (billed for compute); 'PowerState/deallocated' is not billed for compute
        # but its disks and any static public IPs still cost. Both are surfaced.
        $subQueries.Add(@"
resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend powerState = tostring(properties.extended.instanceView.powerState.code)
| where powerState in~ ('PowerState/stopped', 'PowerState/deallocated')
| extend reason = case(
    powerState =~ 'PowerState/stopped', 'VM stopped but not deallocated - still incurring compute charges',
    'VM deallocated - not running (disks and static public IPs may still incur cost)')
| project $cols
"@
)
    }

    if ($Type -contains 'EmptyAppServicePlan') {
        # Free/Shared/Dynamic (consumption) plans are not billed per instance, so an
        # empty one is not a real waste; only fixed-tier plans are flagged.
        $subQueries.Add(@"
resources
| where type =~ 'microsoft.web/serverfarms'
| where toint(properties.numberOfSites) == 0 and tostring(sku.tier) !in~ ('Free', 'Shared', 'Dynamic')
| extend reason = strcat('App Service Plan (', tostring(sku.name), ') with no apps')
| project $cols
"@
)
    }

    if ($Type -contains 'StoppedAksCluster') {
        $subQueries.Add(@"
resources
| where type =~ 'microsoft.containerservice/managedclusters'
| where tostring(properties.powerState.code) =~ 'Stopped'
| extend reason = 'AKS cluster is stopped'
| project $cols
"@
)
    }

    if ($subQueries.Count -eq 1) {
        return $subQueries[0]
    }

    # Azure Resource Graph requires the query to start with a table, then chain
    # '| union (subquery)' for the remaining categories.
    $query = $subQueries[0]
    for ($i = 1; $i -lt $subQueries.Count; $i++) {
        $query += "`n| union (`n$($subQueries[$i])`n)"
    }
    return $query
}