From b9052d74aaf375e6a4e62cc22194f7a7d4601103 Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Mon, 16 Feb 2026 19:19:59 -0800 Subject: [PATCH] Add parameters for noisy recommendations and rename files --- .../finops-hub/createUiDefinition.json | 138 ++++++++++++++++++ src/templates/finops-hub/main.bicep | 12 ++ .../Recommendations/app.bicep | 53 ++++--- ...ecommendations-Microsoft-AdvisorCost.json} | 6 +- ...ons-Microsoft-BackendlessAppGateways.json} | 10 +- ...s-Microsoft-BackendlessLoadBalancers.json} | 10 +- ...tions-Microsoft-EmptySQLElasticPools.json} | 10 +- ...dations-Microsoft-NonSpotAKSClusters.json} | 10 +- ...endations-Microsoft-SQLVMsWithoutAHB.json} | 10 +- ...Recommendations-Microsoft-StoppedVMs.json} | 10 +- ...mendations-Microsoft-UnattachedDisks.json} | 10 +- ...ations-Microsoft-UnattachedPublicIPs.json} | 10 +- ...ommendations-Microsoft-VMsWithoutAHB.json} | 10 +- src/templates/finops-hub/modules/hub.bicep | 14 +- 14 files changed, 240 insertions(+), 73 deletions(-) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-AdvisorCost.json => Recommendations-Microsoft-AdvisorCost.json} (92%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-BackendlessAppGateways.json => Recommendations-Microsoft-BackendlessAppGateways.json} (70%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-BackendlessLoadBalancers.json => Recommendations-Microsoft-BackendlessLoadBalancers.json} (58%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-EmptySQLElasticPools.json => Recommendations-Microsoft-EmptySQLElasticPools.json} (68%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-NonSpotAKSClusters.json => Recommendations-Microsoft-NonSpotAKSClusters.json} (66%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-SQLVMsWithoutAHB.json => Recommendations-Microsoft-SQLVMsWithoutAHB.json} (79%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-StoppedVMs.json => Recommendations-Microsoft-StoppedVMs.json} (59%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-UnattachedDisks.json => Recommendations-Microsoft-UnattachedDisks.json} (65%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-UnattachedPublicIPs.json => Recommendations-Microsoft-UnattachedPublicIPs.json} (73%) rename src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/{HubsRecommendations-VMsWithoutAHB.json => Recommendations-Microsoft-VMsWithoutAHB.json} (76%) diff --git a/src/templates/finops-hub/createUiDefinition.json b/src/templates/finops-hub/createUiDefinition.json index 201aefd47..d91aa8250 100644 --- a/src/templates/finops-hub/createUiDefinition.json +++ b/src/templates/finops-hub/createUiDefinition.json @@ -596,6 +596,141 @@ } ] }, + { + "name": "recommendations", + "label": "🆕 Recommendations", + "elements": [ + { + "name": "recommendationsIntro", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "Uncover hidden savings with FinOps hubs recommendations. FinOps hubs can automatically scan your environment using Azure Resource Graph to surface cost optimization opportunities like idle resources and missed discounts that aren't available in Microsoft Cost Management or Azure Advisor." + } + }, + { + "name": "enableRecommendations", + "type": "Microsoft.Common.CheckBox", + "label": "Enable hubs recommendations (preview)" + }, + { + "name": "included", + "type": "Microsoft.Common.Section", + "label": "Included recommendations", + "visible": "[steps('recommendations').enableRecommendations]", + "elements": [ + { + "name": "includedIntro", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "The following recommendations are always included when enabled:" + } + }, + { + "name": "advisorCost", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Azure Advisor cost recommendations" + } + }, + { + "name": "stoppedVMs", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Stopped (not deallocated) VMs" + } + }, + { + "name": "unattachedDisks", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Unattached managed disks" + } + }, + { + "name": "unattachedPublicIPs", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Unattached static public IPs" + } + }, + { + "name": "emptySQLElasticPools", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Empty SQL elastic pools" + } + }, + { + "name": "backendlessAppGateways", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Application Gateways with empty backend pools" + } + }, + { + "name": "backendlessLoadBalancers", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Load Balancers without backends" + } + } + ] + }, + { + "name": "optional", + "type": "Microsoft.Common.Section", + "label": "Optional recommendations", + "visible": "[steps('recommendations').enableRecommendations]", + "elements": [ + { + "name": "optionalIntro", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "The following recommendations do not apply in all cases and are optional:" + } + }, + { + "name": "enableAHBRecommendations", + "type": "Microsoft.Common.CheckBox", + "label": "➕ Azure Hybrid Benefit", + "toolTip": "Flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses." + }, + { + "name": "enableSpotRecommendations", + "type": "Microsoft.Common.CheckBox", + "label": "➕ Non-Spot AKS cluster", + "toolTip": "Flag AKS clusters with autoscaling but not using Spot VMs. May generate noise since Spot VMs are only appropriate for interruptible workloads." + } + ] + }, + { + "name": "permissions", + "type": "Microsoft.Common.Section", + "label": "Required permissions", + "visible": "[steps('recommendations').enableRecommendations]", + "elements": [ + { + "name": "permissionsNote", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "The Data Factory managed identity requires Reader role on the management groups or subscriptions you want to scan. After deployment, grant the hub's managed identity Reader access to the desired scopes." + } + } + ] + } + ] + }, { "name": "advanced", "label": "Advanced", @@ -777,6 +912,9 @@ "fabricCapacity": "[basics('fabric').fabricCapacity]", "enableInfrastructureEncryption": "[steps('advanced').storage.enableInfrastructureEncryption]", "enableManagedExports": "[steps('advanced').managedExports.enableManagedExports]", + "enableRecommendations": "[steps('recommendations').enableRecommendations]", + "enableAHBRecommendations": "[steps('recommendations').optional.enableAHBRecommendations]", + "enableSpotRecommendations": "[steps('recommendations').optional.enableSpotRecommendations]", "enablePublicAccess": "[steps('advanced').networking.enablePublicAccess]", "virtualNetworkAddressPrefix": "[steps('advanced').networking.virtualNetworkAddressPrefix]", "dataExplorerSku": "[steps('pricing').dataExplorer.dataExplorerSku]", diff --git a/src/templates/finops-hub/main.bicep b/src/templates/finops-hub/main.bicep index 033b19e6f..4e058fcba 100644 --- a/src/templates/finops-hub/main.bicep +++ b/src/templates/finops-hub/main.bicep @@ -39,6 +39,15 @@ param remoteHubStorageKey string = '' @description('Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant User Access Administrator role to FinOps hubs, which is required to create Cost Management exports. Default: true.') param enableManagedExports bool = true +@description('Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. Default: false.') +param enableRecommendations bool = false + +@description('Optional. Enable Azure Hybrid Benefit recommendations that flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses. Requires enableRecommendations. Default: false.') +param enableAHBRecommendations bool = false + +@description('Optional. Enable non-Spot AKS cluster recommendations that flag AKS clusters with autoscaling but not using Spot VMs. May generate noise since Spot VMs are only appropriate for interruptible workloads. Requires enableRecommendations. Default: false.') +param enableSpotRecommendations bool = false + @description('Optional. Name of the Azure Data Explorer cluster to use for advanced analytics. If empty, Azure Data Explorer will not be deployed. Required to use with Power BI if you have more than $2-5M/mo in costs being monitored. Default: "" (do not use).') param dataExplorerName string = '' @@ -168,6 +177,9 @@ module hub 'modules/hub.bicep' = { enableInfrastructureEncryption: enableInfrastructureEncryption enablePurgeProtection: enablePurgeProtection enableManagedExports: enableManagedExports + enableRecommendations: enableRecommendations + enableAHBRecommendations: enableAHBRecommendations + enableSpotRecommendations: enableSpotRecommendations dataExplorerName: dataExplorerName dataExplorerSku: dataExplorerSku dataExplorerCapacity: dataExplorerCapacity diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep index b25f91c42..69f8d51fb 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep @@ -17,6 +17,12 @@ param configContainerName string = 'config' @description('Optional. Name of the ingestion container. Default: ingestion.') param ingestionContainerName string = 'ingestion' +@description('Optional. Whether to enable Azure Hybrid Benefit recommendations. These recommendations flag VMs and SQL VMs without Azure Hybrid Benefit enabled, which may generate noise if your organization does not have on-premises licenses. Default: false.') +param enableAHBRecommendations bool = false + +@description('Optional. Whether to enable non-Spot AKS cluster recommendations. These recommendations flag AKS clusters that use autoscaling without Spot VMs, which may generate noise since Spot VMs are only appropriate for interruptible workloads. Default: false.') +param enableSpotRecommendations bool = false + //============================================================================== // Variables @@ -27,20 +33,29 @@ var QUERIES = 'queries' // Separator used to separate ingestion ID from file name for ingested files var ingestionIdFileNameSeparator = '__' -// Load query files -var queryFiles = { - 'HubsRecommendations-AdvisorCost': loadTextContent('queries/HubsRecommendations-AdvisorCost.json') - 'HubsRecommendations-BackendlessAppGateways': loadTextContent('queries/HubsRecommendations-BackendlessAppGateways.json') - 'HubsRecommendations-BackendlessLoadBalancers': loadTextContent('queries/HubsRecommendations-BackendlessLoadBalancers.json') - 'HubsRecommendations-EmptySQLElasticPools': loadTextContent('queries/HubsRecommendations-EmptySQLElasticPools.json') - 'HubsRecommendations-NonSpotAKSClusters': loadTextContent('queries/HubsRecommendations-NonSpotAKSClusters.json') - 'HubsRecommendations-SQLVMsWithoutAHB': loadTextContent('queries/HubsRecommendations-SQLVMsWithoutAHB.json') - 'HubsRecommendations-StoppedVMs': loadTextContent('queries/HubsRecommendations-StoppedVMs.json') - 'HubsRecommendations-UnattachedDisks': loadTextContent('queries/HubsRecommendations-UnattachedDisks.json') - 'HubsRecommendations-UnattachedPublicIPs': loadTextContent('queries/HubsRecommendations-UnattachedPublicIPs.json') - 'HubsRecommendations-VMsWithoutAHB': loadTextContent('queries/HubsRecommendations-VMsWithoutAHB.json') +// Load query files -- core recommendations are always included +var coreQueryFiles = { + 'Recommendations-Microsoft-AdvisorCost': loadTextContent('queries/Recommendations-Microsoft-AdvisorCost.json') + 'Recommendations-Microsoft-BackendlessAppGateways': loadTextContent('queries/Recommendations-Microsoft-BackendlessAppGateways.json') + 'Recommendations-Microsoft-BackendlessLoadBalancers': loadTextContent('queries/Recommendations-Microsoft-BackendlessLoadBalancers.json') + 'Recommendations-Microsoft-EmptySQLElasticPools': loadTextContent('queries/Recommendations-Microsoft-EmptySQLElasticPools.json') + 'Recommendations-Microsoft-StoppedVMs': loadTextContent('queries/Recommendations-Microsoft-StoppedVMs.json') + 'Recommendations-Microsoft-UnattachedDisks': loadTextContent('queries/Recommendations-Microsoft-UnattachedDisks.json') + 'Recommendations-Microsoft-UnattachedPublicIPs': loadTextContent('queries/Recommendations-Microsoft-UnattachedPublicIPs.json') } +// Optional recommendations that require opt-in due to potential noise +var ahbQueryFiles = enableAHBRecommendations ? { + 'Recommendations-Microsoft-SQLVMsWithoutAHB': loadTextContent('queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json') + 'Recommendations-Microsoft-VMsWithoutAHB': loadTextContent('queries/Recommendations-Microsoft-VMsWithoutAHB.json') +} : {} + +var spotQueryFiles = enableSpotRecommendations ? { + 'Recommendations-Microsoft-NonSpotAKSClusters': loadTextContent('queries/Recommendations-Microsoft-NonSpotAKSClusters.json') +} : {} + +var queryFiles = union(coreQueryFiles, ahbQueryFiles, spotQueryFiles) + // Load schema files var schemaFiles = { 'recommendations_1.0': loadTextContent('schemas/recommendations_1.0.json') @@ -95,12 +110,6 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { dependsOn: [appRegistration] } -// Get storage account instance -resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { - name: app.storage - dependsOn: [appRegistration] -} - //------------------------------------------------------------------------------ // Linked Services //------------------------------------------------------------------------------ @@ -133,7 +142,7 @@ resource linkedService_arm 'Microsoft.DataFactory/factories/linkedservices@2018- //------------------------------------------------------------------------------ // Resource Graph dataset -resource dataset_resourcegraph 'Microsoft.DataFactory/factories/datasets@2018-06-01' = { +resource dataset_resourceGraph 'Microsoft.DataFactory/factories/datasets@2018-06-01' = { name: 'resourceGraph' parent: dataFactory properties: { @@ -688,12 +697,12 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline userProperties: [] typeProperties: { on: { - value: '@pipeline().parameters.inputDataset' + value: '@toLower(pipeline().parameters.inputDataset)' type: 'Expression' } cases: [ { - value: dataset_resourcegraph.name + value: toLower(dataset_resourceGraph.name) // Case-insensitive: match against toLower() of the input activities: [ { name: 'Execute ARG Query' @@ -738,7 +747,7 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline } inputs: [ { - referenceName: dataset_resourcegraph.name + referenceName: dataset_resourceGraph.name type: 'DatasetReference' parameters: {} } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-AdvisorCost.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-AdvisorCost.json similarity index 92% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-AdvisorCost.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-AdvisorCost.json index 1f1b10050..102db2399 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-AdvisorCost.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-AdvisorCost.json @@ -2,9 +2,9 @@ "dataset": "Recommendations", "provider": "Microsoft", "query": "advisorresources | where type == 'microsoft.advisor/recommendations' | where properties.category == 'Cost' | extend x_RecommendationDetails = bag_pack('RecommendationImpact', tostring(properties.impact), 'x_RecommendationProvider', 'Azure Advisor', 'x_RecommendationSolution', tostring(properties.shortDescription.solution), 'x_RecommendationTypeId', tostring(properties.recommendationTypeId), 'x_ResourceType', tolower(properties.impactedField)) | extend x_RecommendationDetails = bag_merge(x_RecommendationDetails, properties.extendedProperties) | project x_RecommendationId=id, x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory=tostring(properties.category), x_RecommendationDescription=tostring(properties.shortDescription.problem), ResourceId=tolower(properties.resourceMetadata.resourceId), ResourceName=tolower(properties.impactedValue), x_RecommendationDetails, x_RecommendationDate=tostring(properties.lastUpdated) | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", + "queryEngine": "ResourceGraph", + "scope": "Tenant", "source": "Azure Advisor", - "type": "HubsRecommendations-AzureAdvisorCost", + "type": "Microsoft-AdvisorCost", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessAppGateways.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json similarity index 70% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessAppGateways.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json index acea1416c..4a5bd534c 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessAppGateways.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "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,resourceGroup=strcat('/subscriptions/',subscriptionId,'/resourceGroups/',resourceGroup)| project id, name, SKUName, SKUTier, SKUCapacity,resourceGroup,subscriptionId, AppGWName=name, type, Location=location| 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 | summarize backendIPCount = sum(backendIPCount) ,backendAddressesCount=sum(backendAddressesCount) by id) on id| project-away id1| where (backendIPCount == 0 or isempty(backendIPCount)) and (backendAddressesCount==0 or isempty(backendAddressesCount))| project x_RecommendationId=strcat(tolower(id),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Application Gateway without any backend pool', ResourceId = tolower(id), ResourceName=tolower(AppGWName), x_RecommendationDetails= strcat('{\"backendIPCount\": ', backendIPCount, ', \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"4f69df93-5972-44e0-97cf-4343c2bcf4b8\", \"x_ResourceType\": \"', type, '\", \"x_RecommendationMaturityLevel\": \"Preview\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-BackendlessAppGateways", + "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,resourceGroup=strcat('/subscriptions/',subscriptionId,'/resourceGroups/',resourceGroup)| project id, name, SKUName, SKUTier, SKUCapacity,resourceGroup,subscriptionId, AppGWName=name, type, Location=location| 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 | summarize backendIPCount = sum(backendIPCount) ,backendAddressesCount=sum(backendAddressesCount) by id) on id| project-away id1| where (backendIPCount == 0 or isempty(backendIPCount)) and (backendAddressesCount==0 or isempty(backendAddressesCount))| project x_RecommendationId=strcat(tolower(id),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Application Gateway without any backend pool', ResourceId = tolower(id), ResourceName=tolower(AppGWName), x_RecommendationDetails= strcat('{\"backendIPCount\": ', backendIPCount, ', \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"4f69df93-5972-44e0-97cf-4343c2bcf4b8\", \"x_ResourceType\": \"', type, '\", \"x_RecommendationMaturityLevel\": \"Preview\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-BackendlessAppGateways", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessLoadBalancers.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json similarity index 58% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessLoadBalancers.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json index cf9c7fc80..94872f08f 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-BackendlessLoadBalancers.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/loadbalancers' and array_length(properties.backendAddressPools) == 0 and sku.name!='Basic' | extend SKUName=tostring(sku.name) | extend SKUTier=tostring(sku.tier), Location=location | extend backendAddressPools = properties.backendAddressPools | extend id,name, SKUName,SKUTier,backendAddressPools, location,resourceGroup, subscriptionId, type| project x_RecommendationId=strcat(tolower(id),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Load balancer without a backend pool', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"SKUName\": \"', SKUName, '\", \"SKUTier\": \"', SKUTier, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"ab703887-fa23-4915-abdc-3defbea89f7a\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-BackendlessLoadBalancers", + "query": "resources | where type =~ 'microsoft.network/loadbalancers' and array_length(properties.backendAddressPools) == 0 and sku.name!='Basic' | extend SKUName=tostring(sku.name) | extend SKUTier=tostring(sku.tier), Location=location | extend backendAddressPools = properties.backendAddressPools | extend id,name, SKUName,SKUTier,backendAddressPools, location,resourceGroup, subscriptionId, type| project x_RecommendationId=strcat(tolower(id),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Load balancer without a backend pool', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"SKUName\": \"', SKUName, '\", \"SKUTier\": \"', SKUTier, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"ab703887-fa23-4915-abdc-3defbea89f7a\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-BackendlessLoadBalancers", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-EmptySQLElasticPools.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json similarity index 68% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-EmptySQLElasticPools.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json index 9fccbf80d..91f8a181c 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-EmptySQLElasticPools.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type == 'microsoft.sql/servers/elasticpools'| extend elasticPoolId = tolower(tostring(id)), elasticPoolName = name, elasticPoolRG = resourceGroup,skuName=tostring(sku.name),skuTier=tostring(sku.tier),skuCapacity=tostring(sku.capacity), Location=location, type| join kind=leftouter ( resources | where type == 'microsoft.sql/servers/databases'| extend elasticPoolId = tolower(tostring(properties.elasticPoolId)) ) on elasticPoolId| summarize databaseCount = countif(isnotempty(elasticPoolId1)) by elasticPoolId, elasticPoolName,serverResourceGroup=resourceGroup,name,skuName,skuTier,skuCapacity,elasticPoolRG,Location, type, subscriptionId| where databaseCount == 0 | project elasticPoolId, elasticPoolName, databaseCount, elasticPoolRG ,skuName,skuTier ,skuCapacity, Location, type, subscriptionId| project x_RecommendationId=strcat(tolower(elasticPoolId),'-idle'), x_ResourceGroupName=tolower(elasticPoolRG), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='SQL Database elastic pool has no associated databases', ResourceId = tolower(elasticPoolId), ResourceName=tolower(elasticPoolName), x_RecommendationDetails= strcat('{\"skuName\": \"', skuName, '\", \"skuTier\": \"', skuTier, '\", \"skuCapacity\": \"', skuCapacity, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"50987aae-a46d-49ae-bd41-a670a4dd18bd\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-EmptySQLElasticPools", + "query": "resources | where type == 'microsoft.sql/servers/elasticpools'| extend elasticPoolId = tolower(tostring(id)), elasticPoolName = name, elasticPoolRG = resourceGroup,skuName=tostring(sku.name),skuTier=tostring(sku.tier),skuCapacity=tostring(sku.capacity), Location=location, type| join kind=leftouter ( resources | where type == 'microsoft.sql/servers/databases'| extend elasticPoolId = tolower(tostring(properties.elasticPoolId)) ) on elasticPoolId| summarize databaseCount = countif(isnotempty(elasticPoolId1)) by elasticPoolId, elasticPoolName,serverResourceGroup=resourceGroup,name,skuName,skuTier,skuCapacity,elasticPoolRG,Location, type, subscriptionId| where databaseCount == 0 | project elasticPoolId, elasticPoolName, databaseCount, elasticPoolRG ,skuName,skuTier ,skuCapacity, Location, type, subscriptionId| project x_RecommendationId=strcat(tolower(elasticPoolId),'-idle'), x_ResourceGroupName=tolower(elasticPoolRG), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='SQL Database elastic pool has no associated databases', ResourceId = tolower(elasticPoolId), ResourceName=tolower(elasticPoolName), x_RecommendationDetails= strcat('{\"skuName\": \"', skuName, '\", \"skuTier\": \"', skuTier, '\", \"skuCapacity\": \"', skuCapacity, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"50987aae-a46d-49ae-bd41-a670a4dd18bd\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-EmptySQLElasticPools", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-NonSpotAKSClusters.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json similarity index 66% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-NonSpotAKSClusters.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json index 8fceee00c..4b2682d6a 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-NonSpotAKSClusters.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type == 'microsoft.containerservice/managedclusters' | mvexpand AgentPoolProfiles = properties.agentPoolProfiles| project id, type, ProfileName = tostring(AgentPoolProfiles.name), Sku = tostring(sku.name), Tier = tostring(sku.tier), mode = AgentPoolProfiles.mode, AutoScaleEnabled = AgentPoolProfiles.enableAutoScaling, SpotVM = AgentPoolProfiles.scaleSetPriority, VMSize = tostring(AgentPoolProfiles.vmSize), NodeCount = tostring(AgentPoolProfiles.['count']), minCount = tostring(AgentPoolProfiles.minCount), maxCount = tostring(AgentPoolProfiles.maxCount), Location=location, resourceGroup, subscriptionId, AKSname = name| where AutoScaleEnabled == 'true' and isnull(SpotVM)| project x_RecommendationId=strcat(tolower(id),'-notSpot'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='The AKS cluster agent pool scale set is not utilizing Spot VMs', ResourceId = tolower(id), ResourceName=tolower(AKSname), x_RecommendationDetails= strcat('{\"AutoScaleEnabled\": ', AutoScaleEnabled, ', \"SpotVM\": \"', SpotVM, '\", \"VMSize\": \"', VMSize, '\", \"Location\": \"', Location, '\", \"NodeCount\": \"', NodeCount, '\", \"minCount\": \"', minCount, '\", \"maxCount\": \"', maxCount, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Consider enabling Spot VMs for this AKS cluster to optimize costs, as Spot VMs offer significantly lower pricing compared to regular VMs\", \"x_RecommendationTypeId\": \"c26abcc2-d5e6-4654-be4a-7d338e5c1e5f\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-NonSpotAKSClusters", + "query": "resources | where type == 'microsoft.containerservice/managedclusters' | mvexpand AgentPoolProfiles = properties.agentPoolProfiles| project id, type, ProfileName = tostring(AgentPoolProfiles.name), Sku = tostring(sku.name), Tier = tostring(sku.tier), mode = AgentPoolProfiles.mode, AutoScaleEnabled = AgentPoolProfiles.enableAutoScaling, SpotVM = AgentPoolProfiles.scaleSetPriority, VMSize = tostring(AgentPoolProfiles.vmSize), NodeCount = tostring(AgentPoolProfiles.['count']), minCount = tostring(AgentPoolProfiles.minCount), maxCount = tostring(AgentPoolProfiles.maxCount), Location=location, resourceGroup, subscriptionId, AKSname = name| where AutoScaleEnabled == 'true' and isnull(SpotVM)| project x_RecommendationId=strcat(tolower(id),'-notSpot'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='The AKS cluster agent pool scale set is not utilizing Spot VMs', ResourceId = tolower(id), ResourceName=tolower(AKSname), x_RecommendationDetails= strcat('{\"AutoScaleEnabled\": ', AutoScaleEnabled, ', \"SpotVM\": \"', SpotVM, '\", \"VMSize\": \"', VMSize, '\", \"Location\": \"', Location, '\", \"NodeCount\": \"', NodeCount, '\", \"minCount\": \"', minCount, '\", \"maxCount\": \"', maxCount, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Consider enabling Spot VMs for this AKS cluster to optimize costs, as Spot VMs offer significantly lower pricing compared to regular VMs\", \"x_RecommendationTypeId\": \"c26abcc2-d5e6-4654-be4a-7d338e5c1e5f\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-NonSpotAKSClusters", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-SQLVMsWithoutAHB.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json similarity index 79% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-SQLVMsWithoutAHB.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json index 2a02db9df..23493b9cd 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-SQLVMsWithoutAHB.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resourcecontainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01' | extend SubscriptionName=name | join ( resources | where type =~ 'Microsoft.SqlVirtualMachine/SqlVirtualMachines' and tostring(properties.['sqlServerLicenseType']) != 'AHUB' | extend SQLID=id, VMName = name, resourceGroup, Location = location, LicenseType = tostring(properties.['sqlServerLicenseType']), OSType=tostring(properties.storageProfile.imageReference.offer), SQLAgentType = tostring(properties.['sqlManagement']), SQLVersion = tostring(properties.['sqlImageOffer']), SQLSKU=tostring(properties.['sqlImageSku'])) on subscriptionId | join ( resources | where type =~ 'Microsoft.Compute/virtualmachines' | extend ActualCores = toint(extract('.[A-Z]([0-9]+)', 1, tostring(properties.hardwareProfile.vmSize))) | project VMName = tolower(name), VMSize = tostring(properties.hardwareProfile.vmSize),ActualCores, subscriptionId, vmType=type, vmResourceGroup=resourceGroup) on VMName| order by id asc | where SQLSKU != 'Developer' and SQLSKU != 'Express'| extend CheckAHBSQLVM= case( type == 'Microsoft.SqlVirtualMachine/SqlVirtualMachines', iif((properties.['sqlServerLicenseType']) != 'AHUB', 'AHB-disabled', 'AHB-enabled'), 'Not Windows')| project SQLID,VMName,resourceGroup, Location, VMSize, SQLVersion, SQLSKU, SQLAgentType, LicenseType, SubscriptionName,type,CheckAHBSQLVM, subscriptionId,ActualCores, vmType, vmResourceGroup| project x_RecommendationId=strcat(tolower(SQLID),'-CheckAHBSQLVM'), x_ResourceGroupName=tolower(vmResourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='SQL virtual machine is not leveraging Azure Hybrid Benefit', ResourceId = tolower(SQLID), ResourceName=tolower(VMName), x_RecommendationDetails= strcat('{\"VMSize\": \"', VMSize, '\", \"CheckAHBSQLVM\": \"', CheckAHBSQLVM, '\", \"ActualCores\": \"', ActualCores, '\", \"SQLVersion\": \"', SQLVersion, '\", \"SQLSKU\": \"', SQLSKU, '\", \"SQLAgentType\": \"', SQLAgentType, '\", \"LicenseType\": \"', LicenseType, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review the SQL licensing option\", \"x_RecommendationTypeId\": \"01decd62-f91b-4434-abe5-9a09e13e018f\", \"x_ResourceType\": \"', vmType, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-SQLVMsWithoutAHB", + "query": "resourcecontainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01' | extend SubscriptionName=name | join ( resources | where type =~ 'Microsoft.SqlVirtualMachine/SqlVirtualMachines' and tostring(properties.['sqlServerLicenseType']) != 'AHUB' | extend SQLID=id, VMName = name, resourceGroup, Location = location, LicenseType = tostring(properties.['sqlServerLicenseType']), OSType=tostring(properties.storageProfile.imageReference.offer), SQLAgentType = tostring(properties.['sqlManagement']), SQLVersion = tostring(properties.['sqlImageOffer']), SQLSKU=tostring(properties.['sqlImageSku'])) on subscriptionId | join ( resources | where type =~ 'Microsoft.Compute/virtualmachines' | extend ActualCores = toint(extract('.[A-Z]([0-9]+)', 1, tostring(properties.hardwareProfile.vmSize))) | project VMName = tolower(name), VMSize = tostring(properties.hardwareProfile.vmSize),ActualCores, subscriptionId, vmType=type, vmResourceGroup=resourceGroup) on VMName| order by id asc | where SQLSKU != 'Developer' and SQLSKU != 'Express'| extend CheckAHBSQLVM= case( type == 'Microsoft.SqlVirtualMachine/SqlVirtualMachines', iif((properties.['sqlServerLicenseType']) != 'AHUB', 'AHB-disabled', 'AHB-enabled'), 'Not Windows')| project SQLID,VMName,resourceGroup, Location, VMSize, SQLVersion, SQLSKU, SQLAgentType, LicenseType, SubscriptionName,type,CheckAHBSQLVM, subscriptionId,ActualCores, vmType, vmResourceGroup| project x_RecommendationId=strcat(tolower(SQLID),'-CheckAHBSQLVM'), x_ResourceGroupName=tolower(vmResourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='SQL virtual machine is not leveraging Azure Hybrid Benefit', ResourceId = tolower(SQLID), ResourceName=tolower(VMName), x_RecommendationDetails= strcat('{\"VMSize\": \"', VMSize, '\", \"CheckAHBSQLVM\": \"', CheckAHBSQLVM, '\", \"ActualCores\": \"', ActualCores, '\", \"SQLVersion\": \"', SQLVersion, '\", \"SQLSKU\": \"', SQLSKU, '\", \"SQLAgentType\": \"', SQLAgentType, '\", \"LicenseType\": \"', LicenseType, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review the SQL licensing option\", \"x_RecommendationTypeId\": \"01decd62-f91b-4434-abe5-9a09e13e018f\", \"x_ResourceType\": \"', vmType, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-SQLVMsWithoutAHB", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-StoppedVMs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json similarity index 59% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-StoppedVMs.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json index c55b49894..28381b674 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-StoppedVMs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.compute/virtualmachines' and tostring(properties.extended.instanceView.powerState.displayStatus) != 'VM deallocated' and tostring(properties.extended.instanceView.powerState.displayStatus) != 'VM running' | extend PowerState=tostring(properties.extended.instanceView.powerState.displayStatus) | extend Location=location, type| project id, PowerState, Location, resourceGroup, subscriptionId, VMName=name, type| project x_RecommendationId=strcat(tolower(id),'-notDeallocated'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual machine is powered off but not deallocated', ResourceId = tolower(id), ResourceName=tolower(VMName), x_RecommendationDetails= strcat('{\"PowerState\": \"', PowerState, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Deallocate the virtual machine to ensure it does not incur in compute costs\", \"x_RecommendationTypeId\": \"ab2ff882-e927-4093-9d11-631be0219975\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-StoppedVMs", + "query": "resources | where type =~ 'microsoft.compute/virtualmachines' and tostring(properties.extended.instanceView.powerState.displayStatus) != 'VM deallocated' and tostring(properties.extended.instanceView.powerState.displayStatus) != 'VM running' | extend PowerState=tostring(properties.extended.instanceView.powerState.displayStatus) | extend Location=location, type| project id, PowerState, Location, resourceGroup, subscriptionId, VMName=name, type| project x_RecommendationId=strcat(tolower(id),'-notDeallocated'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual machine is powered off but not deallocated', ResourceId = tolower(id), ResourceName=tolower(VMName), x_RecommendationDetails= strcat('{\"PowerState\": \"', PowerState, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Deallocate the virtual machine to ensure it does not incur in compute costs\", \"x_RecommendationTypeId\": \"ab2ff882-e927-4093-9d11-631be0219975\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-StoppedVMs", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedDisks.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json similarity index 65% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedDisks.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json index e9d47f436..8245e8add 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedDisks.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.compute/disks' and isempty(managedBy) | extend diskState = tostring(properties.diskState) | where diskState != 'ActiveSAS' and tags !contains 'ASR-ReplicaDisk' and tags !contains 'asrseeddisk' | extend DiskId=id, DiskIDfull=id, DiskName=name, SKUName=sku.name, SKUTier=sku.tier, DiskSizeGB=tostring(properties.diskSizeGB), Location=location, TimeCreated=tostring(properties.timeCreated), SubId=subscriptionId | order by DiskId asc | project DiskId, DiskIDfull, DiskName, DiskSizeGB, SKUName, SKUTier, resourceGroup, Location, TimeCreated, subscriptionId, type| project x_RecommendationId=strcat(tolower(DiskId),'-unattached'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Unattached (orphaned) disk is incurring in storage costs', ResourceId = tolower(DiskId), ResourceName=tolower(DiskName), x_RecommendationDetails= strcat('{\"DiskSizeGB\": ', DiskSizeGB, ', \"SKUName\": \"', SKUName, '\", \"SKUTier\": \"', SKUTier, '\", \"Location\": \"', Location, '\", \"TimeCreated\": \"', TimeCreated, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Remove or downgrade the unattached disk\", \"x_RecommendationTypeId\": \"e0c02939-ce02-4f9d-881f-8067ae7ec90f\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-UnattachedDisks", + "query": "resources | where type =~ 'microsoft.compute/disks' and isempty(managedBy) | extend diskState = tostring(properties.diskState) | where diskState != 'ActiveSAS' and tags !contains 'ASR-ReplicaDisk' and tags !contains 'asrseeddisk' | extend DiskId=id, DiskIDfull=id, DiskName=name, SKUName=sku.name, SKUTier=sku.tier, DiskSizeGB=tostring(properties.diskSizeGB), Location=location, TimeCreated=tostring(properties.timeCreated), SubId=subscriptionId | order by DiskId asc | project DiskId, DiskIDfull, DiskName, DiskSizeGB, SKUName, SKUTier, resourceGroup, Location, TimeCreated, subscriptionId, type| project x_RecommendationId=strcat(tolower(DiskId),'-unattached'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Unattached (orphaned) disk is incurring in storage costs', ResourceId = tolower(DiskId), ResourceName=tolower(DiskName), x_RecommendationDetails= strcat('{\"DiskSizeGB\": ', DiskSizeGB, ', \"SKUName\": \"', SKUName, '\", \"SKUTier\": \"', SKUTier, '\", \"Location\": \"', Location, '\", \"TimeCreated\": \"', TimeCreated, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Remove or downgrade the unattached disk\", \"x_RecommendationTypeId\": \"e0c02939-ce02-4f9d-881f-8067ae7ec90f\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-UnattachedDisks", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedPublicIPs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json similarity index 73% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedPublicIPs.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json index f45b868f0..d1a37133d 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-UnattachedPublicIPs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'Microsoft.Network/publicIPAddresses' and isempty(properties.ipConfiguration) and isempty(properties.natGateway) and properties.publicIPAllocationMethod =~ 'Static' | extend PublicIpId=id, IPName=name, AllocationMethod=tostring(properties.publicIPAllocationMethod), SKUName=sku.name, Location=location | project PublicIpId, IPName, SKUName, resourceGroup, Location, AllocationMethod, subscriptionId, type, name | union ( resources | where type =~ 'microsoft.network/networkinterfaces' and isempty(properties.virtualMachine) and isnull(properties.privateEndpoint) and isnotempty(properties.ipConfigurations) | extend IPconfig = properties.ipConfigurations | mv-expand IPconfig | extend PublicIpId= tostring(IPconfig.properties.publicIPAddress.id) | project PublicIpId, name | join ( resources | where type =~ 'Microsoft.Network/publicIPAddresses'| extend PublicIpId=id, IPName=name, AllocationMethod=tostring(properties.publicIPAllocationMethod), SKUName=sku.name, resourceGroup, Location=location, name, id ) on PublicIpId | extend PublicIpId,IPName, SKUName, resourceGroup, Location, AllocationMethod,name, subscriptionId )| project x_RecommendationId=strcat(tolower(PublicIpId),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Unattached (orphaned) public IP is incurring in networking costs', ResourceId = tolower(PublicIpId), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"SKUName\": \"', SKUName, '\", \"AllocationMethod\": \"', AllocationMethod, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"3ecbf770-9404-4504-a450-cc198e8b2a7d\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-UnattachedPublicIPs", + "query": "resources | where type =~ 'Microsoft.Network/publicIPAddresses' and isempty(properties.ipConfiguration) and isempty(properties.natGateway) and properties.publicIPAllocationMethod =~ 'Static' | extend PublicIpId=id, IPName=name, AllocationMethod=tostring(properties.publicIPAllocationMethod), SKUName=sku.name, Location=location | project PublicIpId, IPName, SKUName, resourceGroup, Location, AllocationMethod, subscriptionId, type, name | union ( resources | where type =~ 'microsoft.network/networkinterfaces' and isempty(properties.virtualMachine) and isnull(properties.privateEndpoint) and isnotempty(properties.ipConfigurations) | extend IPconfig = properties.ipConfigurations | mv-expand IPconfig | extend PublicIpId= tostring(IPconfig.properties.publicIPAddress.id) | project PublicIpId, name | join ( resources | where type =~ 'Microsoft.Network/publicIPAddresses'| extend PublicIpId=id, IPName=name, AllocationMethod=tostring(properties.publicIPAllocationMethod), SKUName=sku.name, resourceGroup, Location=location, name, id ) on PublicIpId | extend PublicIpId,IPName, SKUName, resourceGroup, Location, AllocationMethod,name, subscriptionId )| project x_RecommendationId=strcat(tolower(PublicIpId),'-idle'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Unattached (orphaned) public IP is incurring in networking costs', ResourceId = tolower(PublicIpId), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"SKUName\": \"', SKUName, '\", \"AllocationMethod\": \"', AllocationMethod, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review and remove this resource if not needed\", \"x_RecommendationTypeId\": \"3ecbf770-9404-4504-a450-cc198e8b2a7d\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-UnattachedPublicIPs", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-VMsWithoutAHB.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json similarity index 76% rename from src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-VMsWithoutAHB.json rename to src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json index a0c4fd4c3..6c7d96e88 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/HubsRecommendations-VMsWithoutAHB.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json @@ -1,10 +1,10 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resourcecontainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01' | extend SubscriptionName=name | join ( resources | where type =~ 'microsoft.compute/virtualmachines' or type =~ 'microsoft.compute/virtualMachineScaleSets' | where tostring(properties.storageProfile.imageReference.publisher ) == 'MicrosoftWindowsServer' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.storageProfile.imageReference.publisher ) == 'microsoftsqlserver' | where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) == 'Windows_Server' | extend WindowsId=id, VMSku=tostring(properties.hardwareProfile.vmSize), vmResourceGroup=resourceGroup, vmType=type, Location=location,LicenseType = tostring(properties.['licenseType']) | extend ActualCores = toint(extract('.[A-Z]([0-9]+)', 1, tostring(properties.hardwareProfile.vmSize))) | extend CheckAHBWindows = case( type == 'microsoft.compute/virtualmachines' or type =~ 'microsoft.compute/virtualMachineScaleSets', iif((properties.['licenseType']) !has 'Windows' and (properties.virtualMachineProfile.['licenseType']) !has 'Windows' , 'AHB-disabled', 'AHB-enabled'), 'Not Windows' )) on subscriptionId | project x_RecommendationId=strcat(tolower(WindowsId),'-CheckAHBWindows'), x_ResourceGroupName=tolower(vmResourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Windows virtual machine is not leveraging Azure Hybrid Benefit', ResourceId = tolower(WindowsId), ResourceName=tolower(split(WindowsId,'/')[-1]), x_RecommendationDetails= strcat('{\"VMSku\": \"', VMSku, '\", \"CheckAHBWindows\": \"', CheckAHBWindows, '\", \"ActualCores\": \"', ActualCores, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"Azure Resource Graph\", \"x_RecommendationSolution\": \"Review the virtual machine licensing option\", \"x_RecommendationTypeId\": \"f326c065-b9f7-4a0e-a0f1-5a1c060bc25d\", \"x_ResourceType\": \"', vmType, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", - "queryEngine": "resourceGraph", - "scope": "tenant", - "source": "Azure Resource Graph", - "type": "HubsRecommendations-VMsWithoutAHB", + "query": "resourcecontainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01' | extend SubscriptionName=name | join ( resources | where type =~ 'microsoft.compute/virtualmachines' or type =~ 'microsoft.compute/virtualMachineScaleSets' | where tostring(properties.storageProfile.imageReference.publisher ) == 'MicrosoftWindowsServer' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.storageProfile.imageReference.publisher ) == 'microsoftsqlserver' | where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) == 'Windows_Server' | extend WindowsId=id, VMSku=tostring(properties.hardwareProfile.vmSize), vmResourceGroup=resourceGroup, vmType=type, Location=location,LicenseType = tostring(properties.['licenseType']) | extend ActualCores = toint(extract('.[A-Z]([0-9]+)', 1, tostring(properties.hardwareProfile.vmSize))) | extend CheckAHBWindows = case( type == 'microsoft.compute/virtualmachines' or type =~ 'microsoft.compute/virtualMachineScaleSets', iif((properties.['licenseType']) !has 'Windows' and (properties.virtualMachineProfile.['licenseType']) !has 'Windows' , 'AHB-disabled', 'AHB-enabled'), 'Not Windows' )) on subscriptionId | project x_RecommendationId=strcat(tolower(WindowsId),'-CheckAHBWindows'), x_ResourceGroupName=tolower(vmResourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Windows virtual machine is not leveraging Azure Hybrid Benefit', ResourceId = tolower(WindowsId), ResourceName=tolower(split(WindowsId,'/')[-1]), x_RecommendationDetails= strcat('{\"VMSku\": \"', VMSku, '\", \"CheckAHBWindows\": \"', CheckAHBWindows, '\", \"ActualCores\": \"', ActualCores, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Review the virtual machine licensing option\", \"x_RecommendationTypeId\": \"f326c065-b9f7-4a0e-a0f1-5a1c060bc25d\", \"x_ResourceType\": \"', vmType, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1", + "queryEngine": "ResourceGraph", + "scope": "Tenant", + "source": "FinOps hubs", + "type": "Microsoft-VMsWithoutAHB", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/hub.bicep b/src/templates/finops-hub/modules/hub.bicep index 8fe5e5b81..2ef1a9045 100644 --- a/src/templates/finops-hub/modules/hub.bicep +++ b/src/templates/finops-hub/modules/hub.bicep @@ -47,8 +47,14 @@ param remoteHubStorageKey string = '' @description('Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant User Access Administrator role to FinOps hubs, which is required to create Cost Management exports. Default: true.') param enableManagedExports bool = true -@description('Optional. Enable ARG-based recommendations ingestion. Requires Analytics (ADX or Fabric). Default: true.') -param enableRecommendations bool = true +@description('Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. Default: false.') +param enableRecommendations bool = false + +@description('Optional. Enable Azure Hybrid Benefit recommendations that flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses. Requires enableRecommendations. Default: false.') +param enableAHBRecommendations bool = false + +@description('Optional. Enable non-Spot AKS cluster recommendations that flag AKS clusters with autoscaling but not using Spot VMs. May generate noise since Spot VMs are only appropriate for interruptible workloads. Requires enableRecommendations. Default: false.') +param enableSpotRecommendations bool = false // cSpell:ignore eventhouse @description('Optional. Microsoft Fabric eventhouse query URI. Default: "" (do not use).') @@ -313,7 +319,7 @@ module analytics 'Microsoft.FinOpsHubs/Analytics/app.bicep' = if (useFabric || u // Recommendations app //------------------------------------------------------------------------------ -module recommendations 'Microsoft.FinOpsHubs/Recommendations/app.bicep' = if (enableRecommendations && (useFabric || useAzureDataExplorer)) { +module recommendations 'Microsoft.FinOpsHubs/Recommendations/app.bicep' = if (enableRecommendations) { name: 'Microsoft.FinOpsHubs.Recommendations' dependsOn: [ core @@ -321,6 +327,8 @@ module recommendations 'Microsoft.FinOpsHubs/Recommendations/app.bicep' = if (en ] params: { app: newApp(hub, 'Microsoft.FinOpsHubs', 'Recommendations') + enableAHBRecommendations: enableAHBRecommendations + enableSpotRecommendations: enableSpotRecommendations } }