pwsh/dev/functions/getOrphanedResources.ps1 (452 lines of code) (raw):

function getOrphanedResources { $start = Get-Date Write-Host 'Getting orphaned/unused resources (ARG)' #region queries $queries = [System.Collections.ArrayList]@() $intent = 'cost savings - stopped but not deallocated VM' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.compute/virtualmachines' query = @" resources | where type =~ 'microsoft.compute/virtualmachines' | where properties.extended.instanceView.powerState.code =~ 'PowerState/stopped' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'clean up' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.resources/subscriptions/resourceGroups' query = @" resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourceGroups' | extend rgAndSub = strcat(resourceGroup, '--', subscriptionId) | join kind=leftouter ( resources | extend rgAndSub = strcat(resourceGroup, '--', subscriptionId) | summarize count() by rgAndSub ) on rgAndSub | where isnull(count_) | order by id | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/networkSecurityGroups' query = @" resources | where type =~ 'microsoft.network/networkSecurityGroups' | where isnull(properties.networkInterfaces) and isnull(properties.subnets) | order by id | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/routeTables' query = @" resources | where type =~ 'microsoft.network/routeTables' | where isnull(properties.subnets) | order by id | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/networkInterfaces' query = @" resources | where type =~ 'microsoft.network/networkInterfaces' | where isnull(properties.privateEndpoint) and isnull(properties.privateLinkService) and properties.hostedWorkloads == '[]' and properties !has 'virtualmachine' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.compute/disks' query = @" resources | where type has 'microsoft.compute/disks' | where isempty(managedBy) or properties.diskState =~ 'unattached' and not(name endswith '-ASRReplica' or name startswith 'ms-asr-' or name startswith 'asrseeddisk-') | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/publicIpAddresses' query = @" resources | where type =~ 'microsoft.network/publicIpAddresses' | where properties.ipConfiguration == '' and properties.natGateway == '' and properties.publicIPPrefix == '' and properties.publicIPAllocationMethod =~ 'Static' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/publicIpAddresses' query = @" resources | where type =~ 'microsoft.network/publicIpAddresses' | where properties.ipConfiguration == '' and properties.natGateway == '' and properties.publicIPPrefix == '' and properties.publicIPAllocationMethod =~ 'Dynamic' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.compute/availabilitySets' query = @" resources | where type =~ 'microsoft.compute/availabilitySets' | where properties.virtualMachines == '[]' | where not(name endswith '-asr') | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/loadBalancers' query = @" resources | where type =~ 'microsoft.network/loadBalancers' | where properties.backendAddressPools == '[]' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/applicationGateways' query = @" resources | where type =~ 'microsoft.network/applicationgateways' | extend backendPoolsCount = array_length(properties.backendAddressPools),SKUName= tostring(properties.sku.name), SKUTier= tostring(properties.sku.tier),SKUCapacity=properties.sku.capacity,backendPools=properties.backendAddressPools , AppGwId = tostring(id) | project type, AppGwId, resourceGroup, location, subscriptionId, tags, name, SKUName, SKUTier, SKUCapacity | join ( resources | where type =~ 'microsoft.network/applicationgateways' | mvexpand backendPools = properties.backendAddressPools | extend backendIPCount = array_length(backendPools.properties.backendIPConfigurations) | extend backendAddressesCount = array_length(backendPools.properties.backendAddresses) | extend backendPoolName = backendPools.properties.backendAddressPools.name | extend AppGwId = tostring(id) | summarize backendIPCount = sum(backendIPCount) ,backendAddressesCount=sum(backendAddressesCount) by AppGwId ) on AppGwId | project-away AppGwId1 | where (backendIPCount == 0 or isempty(backendIPCount)) and (backendAddressesCount == 0 or isempty(backendAddressesCount)) | project type, subscriptionId, Resource=AppGwId, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.web/serverfarms' query = @" resources | where type =~ 'microsoft.web/serverfarms' | where properties.numberOfSites == 0 and sku.tier !~ 'Free' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.web/serverfarms' query = @" resources | where type =~ 'microsoft.web/serverfarms' | where properties.numberOfSites == 0 and sku.tier =~ 'Free' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) #new $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.sql/servers/elasticpools' query = @" resources | where type =~ 'microsoft.sql/servers/elasticpools' | project type, elasticPoolId = tolower(id), Resource = id, resourceGroup, location, subscriptionId, tags, properties, Details = pack_all(), Intent='$intent' | join kind=leftouter ( resources | where type =~ 'Microsoft.Sql/servers/databases' | project id, properties | extend elasticPoolId = tolower(properties.elasticPoolId) ) on elasticPoolId | summarize databaseCount = countif(id != '') by type, Resource, subscriptionId, Intent | where databaseCount == 0 | project-away databaseCount "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/trafficmanagerprofiles' query = @" resources | where type =~ 'microsoft.network/trafficmanagerprofiles' | where properties.endpoints == '[]' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/virtualnetworks' query = @" resources | where type =~ 'microsoft.network/virtualnetworks' | where properties.subnets == '[]' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/virtualnetworks/subnets' query = @" resources | where type =~ 'microsoft.network/virtualnetworks' | extend subnet = properties.subnets | mv-expand subnet | extend ipConfigurations = subnet.properties.ipConfigurations | extend delegations = subnet.properties.delegations | where isnull(ipConfigurations) and delegations == '[]' | order by tostring(subnet.id) | project type, subscriptionId, Resource=(subnet.id), Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/natgateways' query = @" resources | where type =~ 'microsoft.network/natgateways' | where isnull(properties.subnets) | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/ipgroups' query = @" resources | where type =~ 'microsoft.network/ipgroups' | where properties.firewalls == '[]' and properties.firewallPolicies == '[]' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/privatednszones' query = @" resources | where type =~ 'microsoft.network/privatednszones' | where properties.numberOfVirtualNetworkLinks == 0 | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/privateendpoints' query = @" resources | where type =~ 'microsoft.network/privateendpoints' | extend connection = iff(array_length(properties.manualPrivateLinkServiceConnections) > 0, properties.manualPrivateLinkServiceConnections[0], properties.privateLinkServiceConnections[0]) | extend stateEnum = tostring(connection.properties.privateLinkServiceConnectionState.status) | where stateEnum =~ 'Disconnected' | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/virtualnetworkgateways' query = @" resources | where type =~ 'microsoft.network/virtualnetworkgateways' | extend vpnClientConfiguration = properties.vpnClientConfiguration | extend Resource = id | join kind=leftouter ( resources | where type =~ 'microsoft.network/connections' | mv-expand Resource = pack_array(properties.virtualNetworkGateway1.id, properties.virtualNetworkGateway2.id) to typeof(string) | project Resource, connectionId = id, ConnectionProperties=properties ) on Resource | where isempty(vpnClientConfiguration) and isempty(connectionId) | project type, subscriptionId, Resource, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.network/ddosprotectionplans' query = @" resources | where type =~ 'microsoft.network/ddosprotectionplans' | where isnull(properties.virtualNetworks) | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) $intent = 'misconfiguration' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.Web/connections' query = @" resources | where type =~ 'Microsoft.Web/connections' | project type, resourceId = id , apiName = name, subscriptionId, resourceGroup, tags, location | join kind = leftouter ( resources | where type =~ 'microsoft.logic/workflows' | extend resourceGroup, location, subscriptionId, properties | extend var_json = properties['parameters']['`$connections']['value'] | mvexpand var_connection = var_json | where notnull(var_connection) | extend connectionId = extract('connectionId\\\":\\\"(.*?)\\\"', 1, tostring(var_connection)) | project connectionId, name ) on `$left.resourceId == `$right.connectionId | where connectionId == '' | project type, subscriptionId, Resource=resourceId, Intent='$intent' "@ intent = $intent }) $intent = 'cost savings' $null = $queries.Add([PSCustomObject]@{ queryName = 'microsoft.Web/certificates' query = @" resources | where type =~ 'microsoft.web/certificates' | extend expiresOn = todatetime(properties.expirationDate) | where expiresOn <= now() | project type, subscriptionId, Resource=id, Intent='$intent' "@ intent = $intent }) #endregion queries $batchSize = [math]::ceiling($queries.Count / $azAPICallConf['htParameters'].ThrottleLimit) #Write-Host "Optimal batch size: $($batchSize)" $counterBatch = [PSCustomObject] @{ Value = 0 } $queriesBatch = ($queries) | Group-Object -Property { [math]::Floor($counterBatch.Value++ / $batchSize) } Write-Host " Processing queries in $($queriesBatch.Count) batches" $queriesBatch | ForEach-Object -Parallel { $arrayOrphanedResources = $using:arrayOrphanedResources $subsToProcessInCustomDataCollection = $using:subsToProcessInCustomDataCollection $azAPICallConf = $using:azAPICallConf foreach ($queryDetail in $_.Group) { #Batching: https://learn.microsoft.com/azure/governance/resource-graph/troubleshoot/general#toomanysubscription $counterBatch = [PSCustomObject] @{ Value = 0 } $batchSize = 1000 $subscriptionsBatch = $subsToProcessInCustomDataCollection | Group-Object -Property { [math]::Floor($counterBatch.Value++ / $batchSize) } $uri = "$($azAPICallConf['azAPIEndpointUrls'].ARM)/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01" $method = 'POST' foreach ($batch in $subscriptionsBatch) { Write-Host " Getting orphaned $($queryDetail.queryName) for $($batch.Group.subscriptionId.Count) Subscriptions" $subscriptions = '"{0}"' -f ($batch.Group.subscriptionId -join '","') $body = @" { "query": "$($queryDetail.query)", "subscriptions": [$($subscriptions)], "options": { "`$top": 1000 } } "@ $res = (AzAPICall -AzAPICallConfiguration $azAPICallConf -uri $uri -method $method -body $body -listenOn 'Content' -currentTask "Getting orphaned $($queryDetail.queryName)") if ($res.count -gt 0) { foreach ($resource in $res) { $null = $script:arrayOrphanedResources.Add($resource) } } Write-Host " $($res.count) orphaned $($queryDetail.queryName) found" } } } -ThrottleLimit ($azAPICallConf['htParameters'].ThrottleLimit) if ($arrayOrphanedResources.Count -gt 0) { if ($azAPICallConf['htParameters'].DoAzureConsumption -eq $true) { $allConsumptionDataGroupedByTypeAndCurrency = $allConsumptionData | Group-Object -Property ResourceType, Currency $orphanedResourcesResourceTypesCostRelevant = ($queries.where({ $_.intent -like 'cost savings*' })).queryName $htC = @{} foreach ($consumptionResourceTypeAndCurrency in $allConsumptionDataGroupedByTypeAndCurrency) { $consumptionResourceTypeAndCurrencySplitted = $consumptionResourceTypeAndCurrency.Name.split(', ') #$consumptionResourceTypeAndCurrencySplitted[0] if ($consumptionResourceTypeAndCurrencySplitted[0] -in $orphanedResourcesResourceTypesCostRelevant ) { foreach ($entry in $consumptionResourceTypeAndCurrency.Group) { if (-not $htC.($entry.resourceId)) { $htC.($entry.resourceId) = @{ cost = $entry.PreTaxCost currency = $entry.Currency } } else { $htC.($entry.resourceId).cost = $htC.($entry.resourceId).cost + $entry.PreTaxCost } } } } $costrelevantOrphanedResourcesGroupedByType = ($arrayOrphanedResources | Group-Object -Property intent).where({ $_.name -like 'cost savings*' }).group | Group-Object -Property type $nonCostrelevantOrphanedResourcesGroupedByType = ($arrayOrphanedResources | Group-Object -Property intent).where({ $_.name -notlike 'cost savings*' }).group | Group-Object -Property type $script:arrayOrphanedResources = [System.Collections.ArrayList]@() foreach ($costrelevantOrphanedResourceType in $costrelevantOrphanedResourcesGroupedByType) { foreach ($resource in $costrelevantOrphanedResourceType.Group) { if ($htC.($resource.Resource)) { $null = $script:arrayOrphanedResources.Add([PSCustomObject]@{ Type = $costrelevantOrphanedResourceType.Name Resource = $resource.Resource SubscriptionId = $resource.subscriptionId Intent = $resource.Intent Cost = $htC.($resource.Resource).cost Currency = $htC.($resource.Resource).currency }) } else { $null = $script:arrayOrphanedResources.Add([PSCustomObject]@{ Type = $costrelevantOrphanedResourceType.Name Resource = $resource.Resource SubscriptionId = $resource.subscriptionId Intent = $resource.Intent Cost = '' Currency = '' }) } } } foreach ($nonCostrelevantOrphanedResourceType in $nonCostrelevantOrphanedResourcesGroupedByType) { Write-Host "Processing $($nonCostrelevantOrphanedResourceType.Name)" foreach ($resource in $nonCostrelevantOrphanedResourceType.Group) { $null = $script:arrayOrphanedResources.Add([PSCustomObject]@{ Type = $nonCostrelevantOrphanedResourceType.Name Resource = $resource.Resource SubscriptionId = $resource.subscriptionId Intent = $resource.Intent Cost = '' Currency = '' }) } } } Write-Host " Found $($arrayOrphanedResources.Count) orphaned/unused Resources" if (-not $NoCsvExport) { Write-Host " Exporting OrphanedResources CSV '$($outputPath)$($DirectorySeparatorChar)$($fileName)_ResourcesCostOptimizationAndCleanup.csv'" $arrayOrphanedResources | Sort-Object -Property Resource | Export-Csv -Path "$($outputPath)$($DirectorySeparatorChar)$($fileName)_ResourcesCostOptimizationAndCleanup.csv" -Delimiter "$csvDelimiter" -NoTypeInformation } } else { Write-Host ' No orphaned/unused Resources found' } $end = Get-Date Write-Host "Getting orphaned/unused resources (ARG) processing duration: $((New-TimeSpan -Start $start -End $end).TotalMinutes) minutes ($((New-TimeSpan -Start $start -End $end).TotalSeconds) seconds)" }