diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 6484e4a02..b44826a73 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 07/15/2026 +ms.date: 07/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -63,6 +63,7 @@ _Released June 2026_ - Added three new FOCUS 1.4 datasets: `ContractCommitments` (30 columns, using FOCUS 1.4 specification column IDs like `ContractCommitmentBenefitCategory` and `ContractCommitmentLifecycleStatus`), `BillingPeriods` (6 columns), and `InvoiceDetails` (22 columns), each with tables and versioned and unversioned functions (for example, `ContractCommitments()` and `ContractCommitments_v1_4()`). - These datasets remain empty until Cost Management supports exporting FOCUS 1.4 data. - Added a static test harness for the hub database setup scripts that verifies build registration, schema version consistency, and update policy state to catch regressions before release. + - Added `x_RecommendationCategory` and `x_RecommendationSubcategory` enrichment columns to the `Recommendations` dataset, normalized to a fixed hub taxonomy (Cost, Operational Excellence, Performance, Reliability, Security). Unused commitment capacity maps to `Commitment Discount Utilization`; idle and orphaned provisioned resources map to `Idle Resources`. See [data model](hubs/data-model.md#recommendations-managed-dataset) for the allowed values. - **Changed** - Retargeted unversioned `Costs()`, `Prices()`, `CommitmentDiscountUsage()`, `Recommendations()`, and `Transactions()` aliases to their `_v1_4` counterparts. - This is **not** a breaking change if you are following the prescribed guidance of using versioned functions. diff --git a/docs-mslearn/toolkit/hubs/configure-recommendations.md b/docs-mslearn/toolkit/hubs/configure-recommendations.md index aa7644600..9e8ba69ff 100644 --- a/docs-mslearn/toolkit/hubs/configure-recommendations.md +++ b/docs-mslearn/toolkit/hubs/configure-recommendations.md @@ -3,7 +3,7 @@ title: Configure FinOps hubs recommendations description: Learn about the recommendations available in FinOps hubs and how to add custom recommendations. author: flanakin ms.author: micflan -ms.date: 04/01/2026 +ms.date: 07/16/2026 ms.topic: how-to ms.service: finops ms.subservice: finops-toolkit @@ -159,10 +159,10 @@ Your query must return the following columns: | `ResourceName` | Resource name (lowercase). | | `SubAccountId` | Subscription ID. | | `SubAccountName` | Subscription name. Join with `resourcecontainers` to populate this. | -| `x_RecommendationCategory` | Recommendation category. Use `"Cost"`, `"HighAvailability"`, `"OperationalExcellence"`, `"Performance"`, or `"Security"`. | +| `x_RecommendationCategory` | Recommendation category. Use `"Cost"`, `"HighAvailability"`, `"OperationalExcellence"`, `"Performance"`, or `"Security"`. Values are normalized to the [hub recommendation taxonomy](data-model.md#recommendations-managed-dataset) on ingestion. | | `x_RecommendationDate` | Recommendation date (use `now()` for point-in-time queries). | | `x_RecommendationDescription` | Short description of the issue. | -| `x_RecommendationDetails` | JSON string with additional properties. Include `x_RecommendationProvider`, `x_RecommendationSolution`, `x_RecommendationTypeId`, and `x_ResourceType` along with any custom properties specific to the recommendation. | +| `x_RecommendationDetails` | JSON string with additional properties. Include `x_RecommendationProvider`, `x_RecommendationSolution`, `x_RecommendationSubcategory` (an allowed value from the [hub recommendation taxonomy](data-model.md#recommendations-managed-dataset); unmapped values become `Other`), `x_RecommendationTypeId`, and `x_ResourceType` along with any custom properties specific to the recommendation. | | `x_RecommendationId` | Unique identifier for the recommendation (for example, resource ID + suffix). | | `x_ResourceGroupName` | Resource group name (lowercase). | diff --git a/docs-mslearn/toolkit/hubs/data-model.md b/docs-mslearn/toolkit/hubs/data-model.md index e06a19519..949ee86c8 100644 --- a/docs-mslearn/toolkit/hubs/data-model.md +++ b/docs-mslearn/toolkit/hubs/data-model.md @@ -3,7 +3,7 @@ title: FinOps hubs data model description: Learn about the tables and functions available in FinOps hubs to build your own queries, reports, and dashboards. author: flanakin ms.author: micflan -ms.date: 04/01/2026 +ms.date: 07/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -736,7 +736,8 @@ Columns in the **Recommendations** managed dataset include: | x_EffectiveCostBefore | Real | Source: Microsoft EA+MCA 2023-05-01. | | x_EffectiveCostSavings | Real | Source: Microsoft EA+MCA 2023-05-01. | | x_IngestionTime | Datetime | Source: Hubs add-on. | -| x_RecommendationCategory | String | Source: Hubs recommendations. | +| x_RecommendationCategory | String | Source: Hubs recommendations. Normalized to the hub taxonomy. | +| x_RecommendationSubcategory | String | Source: Hubs add-on. Normalized to the hub taxonomy. | | x_RecommendationDate | Datetime | Source: Microsoft EA+MCA 2023-05-01. | | x_RecommendationDescription | String | Source: Hubs add-on. | | x_RecommendationDetails | Dynamic | Contains source-specific metadata. See notes below the table. | @@ -747,7 +748,80 @@ Columns in the **Recommendations** managed dataset include: | x_SourceType | String | Source: Hubs add-on. | | x_SourceVersion | String | Source: Hubs add-on. | -The **x_RecommendationDetails** column is a dynamic object that contains source-specific metadata. For Cost Management reservation recommendations, it includes commitment discount details like scope, term, SKU, and region. For hubs recommendations, it includes the recommendation provider, solution, type ID, resource type, and any additional properties from the query. +The **x_RecommendationDetails** column is a dynamic object that contains source-specific metadata. For Cost Management reservation recommendations, it includes commitment discount details like scope, term, SKU, and region. For hubs recommendations, it includes the recommendation provider, solution, subcategory, type ID, resource type, and any additional properties from the query. + +The **x_RecommendationCategory** and **x_RecommendationSubcategory** columns classify each recommendation against a fixed hub taxonomy so reports can group across providers consistently. The lists below are canonical for the FOCUS 1.4 hub schema: new values may be added in a future release, but existing values won't be renamed or removed without a schema version bump, so reports can safely hard-code filters and slicers against them. + +Both columns always have a value. Source data is normalized on ingestion using the following rules: + +- Source category values are mapped to the allowed values below (see the source mapping table). The comparison is case-insensitive, but the stored value always uses the exact casing below. +- The source subcategory is read from the `x_RecommendationSubcategory` property of **x_RecommendationDetails** and only passes through when it's an allowed value that belongs to the resolved category. A subcategory that contradicts the category is treated as a source defect and becomes `Other`. +- If the source category is unknown but the subcategory is a valid taxonomy value, the category is backfilled from the subcategory's parent. +- Anything still unresolved becomes `Other` for both columns. A source that explicitly sets `Other` is indistinguishable from an unmapped value. + +Source category values map to the hub taxonomy as follows: + +| Source | Source value | x_RecommendationCategory | +| --------------------- | -------------------------- | ------------------------ | +| Azure Advisor | Cost | Cost | +| Azure Advisor | HighAvailability | Reliability | +| Azure Advisor | OperationalExcellence | Operational Excellence | +| Azure Advisor | Performance | Performance | +| Azure Advisor | Security | Security | +| Cost Management | ReservationRecommendations | Cost | +| FinOps hubs (queries) | Cost | Cost | + +Allowed **x_RecommendationCategory** values: `Cost`, `Operational Excellence`, `Performance`, `Reliability`, `Security`, and `Other` (fallback when the source category can't be mapped). + +Allowed **x_RecommendationSubcategory** values, grouped by parent category: + +- Cost + - Autoscaling + - Commitment Discount Coverage (which commitments to buy) + - Commitment Discount Utilization (paid-for commitment hours not being consumed) + - Idle Resources (resources doing no useful work, including stopped, unattached, and orphaned resources; remediation is stop, deallocate, or delete) + - License Optimization (bring-your-own-license benefits like Azure Hybrid Benefit) + - Low Utilization (running resources that are underused relative to their size; remediation is resize or consolidate) + - Negotiated Discounts + - Region Placement + - Right-Sizing (size down to reduce cost; sizing up to meet demand is Resource Sizing under Performance) + - Scheduling + - Service Selection and Architecture + - SKU Modernization (move off retired or legacy SKUs and service tiers) + - Spot Eligibility + - Storage Tiering (move data to a cheaper storage tier or SKU) +- Operational Excellence + - Automation and Process + - Best Practices + - Governance and Policy + - Observability + - Operational Hygiene + - Quotas and Limits (quota recommendations always land here, even when quota exhaustion has reliability consequences) + - Resource Consistency +- Performance + - Performance Tuning + - Resource Configuration + - Resource Sizing (size up to meet performance needs; sizing down for cost is Right-Sizing under Cost) + - Scaling and Capacity (scale out for throughput; scaling down to save is Autoscaling under Cost) + - Throughput and Latency + - Workload Placement +- Reliability + - Backup Configuration (backup availability: RPO, retention, and geo-redundancy; backup confidentiality like encryption is Data Protection under Security) + - Capacity (capacity headroom for failover and demand spikes) + - Disaster Recovery + - High Availability + - Service Retirement + - Zone Resiliency +- Security + - Compliance Alignment + - Data Protection + - Identity and Access + - Network Security + - Threat Detection + - Vulnerability Exposure +- Other (fallback when no specific subcategory applies) + +Common recommendation types that don't have a dedicated subcategory yet (for example, data transfer and egress optimization, cost allocation and tagging hygiene, and marketplace optimization) intentionally fall back to `Other` today. They're candidates for future additions once a recommendation source in the toolkit produces them; suggest more in the [issue tracker](https://github.com/microsoft/finops-toolkit/issues).
diff --git a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 index aed18feac..d2c01509b 100644 --- a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 @@ -505,6 +505,164 @@ Describe 'HubsFocusSchemas' { } } + Context 'Recommendations taxonomy (W9)' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $recommendationQueriesPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries' + # AdvisorCost passes Azure Advisor's per-row category through and has no single subcategory; + # every other hub-native query targets one recommendation type and must declare one. + $recommendationQueryFiles = @(Get-ChildItem -Path $recommendationQueriesPath -Filter 'Recommendations-*.json' | + Where-Object { $_.Name -ne 'Recommendations-Microsoft-AdvisorCost.json' } | + ForEach-Object { @{ Name = $_.Name; FullName = $_.FullName } }) + } + + BeforeAll { + $script:recommendationsTransformV14 = if ($ingestionFiles.v1_4 -match '(?ms)Recommendations_transform_v1_4\(\)\s*\{(.*?)\n\}') { $Matches[1] } else { '' } + $script:recommendationsFinalV14Block = if ($ingestionFiles.v1_4 -match '(?ms)\.create-merge table Recommendations_final_v1_4 \(\r?\n(.*?)\r?\n\)') { $Matches[1] } else { '' } + + # Canonical (subcategory -> parent category) taxonomy. This is the test's source of truth; + # the KQL datatable and the data-model.md docs are both validated against it. + $script:canonicalTaxonomy = [ordered]@{ + # Cost + 'Autoscaling' = 'Cost' + 'Commitment Discount Coverage' = 'Cost' + 'Commitment Discount Utilization' = 'Cost' + 'Idle Resources' = 'Cost' + 'License Optimization' = 'Cost' + 'Low Utilization' = 'Cost' + 'Negotiated Discounts' = 'Cost' + 'Region Placement' = 'Cost' + 'Right-Sizing' = 'Cost' + 'Scheduling' = 'Cost' + 'Service Selection and Architecture' = 'Cost' + 'SKU Modernization' = 'Cost' + 'Spot Eligibility' = 'Cost' + 'Storage Tiering' = 'Cost' + # Operational Excellence + 'Automation and Process' = 'Operational Excellence' + 'Best Practices' = 'Operational Excellence' + 'Governance and Policy' = 'Operational Excellence' + 'Observability' = 'Operational Excellence' + 'Operational Hygiene' = 'Operational Excellence' + 'Quotas and Limits' = 'Operational Excellence' + 'Resource Consistency' = 'Operational Excellence' + # Performance + 'Performance Tuning' = 'Performance' + 'Resource Configuration' = 'Performance' + 'Resource Sizing' = 'Performance' + 'Scaling and Capacity' = 'Performance' + 'Throughput and Latency' = 'Performance' + 'Workload Placement' = 'Performance' + # Reliability + 'Backup Configuration' = 'Reliability' + 'Capacity' = 'Reliability' + 'Disaster Recovery' = 'Reliability' + 'High Availability' = 'Reliability' + 'Service Retirement' = 'Reliability' + 'Zone Resiliency' = 'Reliability' + # Security + 'Compliance Alignment' = 'Security' + 'Data Protection' = 'Security' + 'Identity and Access' = 'Security' + 'Network Security' = 'Security' + 'Threat Detection' = 'Security' + 'Vulnerability Exposure' = 'Security' + } + + # Parse the (subcategory, parent) pairs out of the SubcategoryTaxonomy datatable in the transform. + $script:kqlTaxonomy = [ordered]@{} + $datatableBlock = if ($recommendationsTransformV14 -match "(?ms)let SubcategoryTaxonomy = datatable[^\[]+\[(.*?)\];") { $Matches[1] } else { '' } + foreach ($pair in [regex]::Matches($datatableBlock, "(?m)^\s*'([^']+)',\s*'([^']+)',?\s*$")) + { + $kqlTaxonomy[$pair.Groups[1].Value] = $pair.Groups[2].Value + } + + $script:dataModelContent = Get-Content -Path (Join-Path $repoRoot 'docs-mslearn/toolkit/hubs/data-model.md') -Raw + } + + It 'Recommendations_transform_v1_4 block was extracted' { + $recommendationsTransformV14 | Should -Not -BeNullOrEmpty + } + + It 'Recommendations_final_v1_4 block was extracted' { + $recommendationsFinalV14Block | Should -Not -BeNullOrEmpty + } + + It 'Recommendations_final_v1_4 includes column <_>' -ForEach @( + 'x_RecommendationCategory', 'x_RecommendationSubcategory' + ) { + $recommendationsFinalV14Block | Should -Match "(?m)^\s+$_\s*:" + } + + It 'Recommendations_transform_v1_4 projects <_>' -ForEach @( + 'x_RecommendationCategory', 'x_RecommendationSubcategory' + ) { + $recommendationsTransformV14 | Should -Match "(?m)^\s+$_\s*,?\s*(//.*)?$" + } + + It 'Recommendations_v1_4 hub function projects <_>' -ForEach @( + 'x_RecommendationCategory', 'x_RecommendationSubcategory' + ) { + $recommendationsV14Block = if ($hubFiles.v1_4 -match '(?ms)Recommendations_v1_4\(\)\s*\{(.*?)\n\}') { $Matches[1] } else { '' } + $recommendationsV14Block | Should -Match "(?m)^\s+$_\s*,?\s*$" + } + + It 'Recommendations_transform_v1_4 normalizes category to <_>' -ForEach @( + "'Cost'", "'Operational Excellence'", "'Performance'", "'Reliability'", "'Security'" + ) { + $recommendationsTransformV14 | Should -Match ([regex]::Escape($_)) + } + + It 'Recommendations_transform_v1_4 maps Advisor HighAvailability to Reliability' { + $recommendationsTransformV14 | Should -Match "x_RecommendationCategory =~ 'HighAvailability', 'Reliability'" + } + + It 'SubcategoryTaxonomy datatable matches the canonical taxonomy exactly' { + $kqlTaxonomy.Count | Should -Be $canonicalTaxonomy.Count -Because 'the KQL datatable and the canonical taxonomy must not drift' + foreach ($subcategory in $canonicalTaxonomy.Keys) + { + $kqlTaxonomy[$subcategory] | Should -Be $canonicalTaxonomy[$subcategory] -Because "subcategory '$subcategory' must map to '$($canonicalTaxonomy[$subcategory])' in the SubcategoryTaxonomy datatable" + } + } + + It 'data-model.md documents every canonical subcategory' { + foreach ($subcategory in $canonicalTaxonomy.Keys) + { + $dataModelContent | Should -Match ([regex]::Escape($subcategory)) -Because "docs must list canonical subcategory '$subcategory'" + } + } + + It 'Backfills an unknown category from the subcategory parent (coherence rule)' { + # An empty category with a valid subcategory must resolve to the subcategory's parent so a + # specific subcategory can never pair with an unclassified category. + $recommendationsTransformV14 | Should -Match "(?m)^\s*isnotempty\(tmp_SubcategoryParent\),\s*tmp_SubcategoryParent," + } + + It 'Only passes a subcategory through when its parent matches the resolved category (coherence rule)' { + $recommendationsTransformV14 | Should -Match ([regex]::Escape("tmp_SubcategoryParent == x_RecommendationCategory")) + } + + It 'Defaults both columns to Other when unmapped' { + # Category fallback: the backfill case ends with 'Other'. + $recommendationsTransformV14 | Should -Match "(?ms)x_RecommendationCategory = case\((?:(?!\)\r?\n).)*'Other'\r?\n\s*\)" + # Subcategory fallback: the subcategory case ends with 'Other'. + $recommendationsTransformV14 | Should -Match "(?ms)x_RecommendationSubcategory = case\((?:(?!\)\r?\n).)*'Other'\r?\n\s*\)" + } + + It 'Defaults reservation recommendations to Commitment Discount Coverage' { + $recommendationsTransformV14 | Should -Match ([regex]::Escape("x_SourceType == 'ReservationRecommendations', 'Commitment Discount Coverage'")) + } + + It ' declares a canonical Cost subcategory' -ForEach $recommendationQueryFiles { + $query = (Get-Content -Path $FullName -Raw | ConvertFrom-Json).query + $query -match "'x_RecommendationSubcategory',\s*'([^']+)'" | Should -BeTrue -Because 'every hub-native recommendation query must declare a subcategory in x_RecommendationDetails' + $subcategory = $Matches[1] + $canonicalTaxonomy.Keys | Should -Contain $subcategory -Because "subcategory '$subcategory' in '$Name' must be a canonical taxonomy value" + $canonicalTaxonomy[$subcategory] | Should -Be 'Cost' -Because "hub-native queries hardcode x_RecommendationCategory='Cost', so the declared subcategory must be a Cost subcategory or ingestion will coerce it to 'Other'" + } + } + Context 'HubSetup_Latest.kql aliases' { It 'Aliases <_>() to <_>_v1_4() (latest GA)' -ForEach $allDatasets { diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_4.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_4.kql index 04b655082..c1a02d177 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_4.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_4.kql @@ -585,6 +585,7 @@ Recommendations_v1_4() x_EffectiveCostSavings, x_IngestionTime, x_RecommendationCategory, + x_RecommendationSubcategory, x_RecommendationDate, x_RecommendationDescription, x_RecommendationDetails, diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4_Datasets.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4_Datasets.kql index 458e110ca..7e1ecea46 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4_Datasets.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4_Datasets.kql @@ -391,6 +391,56 @@ Recommendations_transform_v1_4() -1 )) }; + // Canonical hub recommendation taxonomy: every subcategory and its parent category. + // Keep in sync with docs-mslearn/toolkit/hubs/data-model.md#recommendations-managed-dataset and the + // taxonomy tests in src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1. New values require a docs + // and test update; renames/removals require a schema version bump. + let SubcategoryTaxonomy = datatable(tmp_Subcategory: string, tmp_SubcategoryParent: string) [ + // Cost + 'Autoscaling', 'Cost', + 'Commitment Discount Coverage', 'Cost', + 'Commitment Discount Utilization', 'Cost', + 'Idle Resources', 'Cost', + 'License Optimization', 'Cost', + 'Low Utilization', 'Cost', + 'Negotiated Discounts', 'Cost', + 'Region Placement', 'Cost', + 'Right-Sizing', 'Cost', + 'Scheduling', 'Cost', + 'Service Selection and Architecture', 'Cost', + 'SKU Modernization', 'Cost', + 'Spot Eligibility', 'Cost', + 'Storage Tiering', 'Cost', + // Operational Excellence + 'Automation and Process', 'Operational Excellence', + 'Best Practices', 'Operational Excellence', + 'Governance and Policy', 'Operational Excellence', + 'Observability', 'Operational Excellence', + 'Operational Hygiene', 'Operational Excellence', + 'Quotas and Limits', 'Operational Excellence', + 'Resource Consistency', 'Operational Excellence', + // Performance + 'Performance Tuning', 'Performance', + 'Resource Configuration', 'Performance', + 'Resource Sizing', 'Performance', + 'Scaling and Capacity', 'Performance', + 'Throughput and Latency', 'Performance', + 'Workload Placement', 'Performance', + // Reliability + 'Backup Configuration', 'Reliability', + 'Capacity', 'Reliability', + 'Disaster Recovery', 'Reliability', + 'High Availability', 'Reliability', + 'Service Retirement', 'Reliability', + 'Zone Resiliency', 'Reliability', + // Security + 'Compliance Alignment', 'Security', + 'Data Protection', 'Security', + 'Identity and Access', 'Security', + 'Network Security', 'Security', + 'Threat Detection', 'Security', + 'Vulnerability Exposure', 'Security' + ]; Recommendations_raw | extend x_IngestionTime = ingestion_time() // @@ -461,6 +511,48 @@ Recommendations_transform_v1_4() // Set ResourceType display name from x_ResourceType code | extend ResourceType = coalesce(ResourceType, tostring(resource_type(tmp_ResourceType).SingularDisplayName), tmp_ResourceType) // + // Classify against the hub recommendation taxonomy (see data-model.md#recommendations-managed-dataset): + // 1. Normalize the source category to Cost, Operational Excellence, Performance, Reliability, or Security. + // Azure Advisor reports PascalCase values (Cost, HighAvailability, OperationalExcellence, Performance, + // Security); =~ hardens against unexpected casing from custom sources. Reservation recommendations from + // Cost Management never set a category and are always cost recommendations. + // 2. Read the source subcategory from x_RecommendationDetails.x_RecommendationSubcategory (hub-native + // Resource Graph queries and custom queries declare it there) and resolve its parent category. + // 3. Enforce coherence: an unknown category is backfilled from a valid subcategory's parent; a subcategory + // only passes through when its parent matches the resolved category (a mismatched pair is a source + // defect, so the subcategory falls back to 'Other' rather than contradicting the category). + // 4. Both columns always have a value: anything still unresolved becomes 'Other'. + | extend x_RecommendationCategory = tostring(case( + // Already normalized values pass through + x_RecommendationCategory in ('Cost', 'Operational Excellence', 'Performance', 'Reliability', 'Security'), x_RecommendationCategory, + // Azure Advisor PascalCase values + x_RecommendationCategory =~ 'Cost', 'Cost', + x_RecommendationCategory =~ 'HighAvailability', 'Reliability', + x_RecommendationCategory =~ 'OperationalExcellence', 'Operational Excellence', + x_RecommendationCategory =~ 'Performance', 'Performance', + x_RecommendationCategory =~ 'Reliability', 'Reliability', + x_RecommendationCategory =~ 'Security', 'Security', + // Reservation recommendations never set a category and are always cost recommendations + isempty(x_RecommendationCategory) and x_SourceType == 'ReservationRecommendations', 'Cost', + // Unknown source value; may be backfilled from the subcategory below + '' + )) + | extend tmp_Subcategory = tostring(x_RecommendationDetails.x_RecommendationSubcategory) + | lookup kind=leftouter SubcategoryTaxonomy on tmp_Subcategory + // Backfill an unknown category from the subcategory's parent; anything still unknown becomes 'Other' + | extend x_RecommendationCategory = case( + isnotempty(x_RecommendationCategory), x_RecommendationCategory, + isnotempty(tmp_SubcategoryParent), tmp_SubcategoryParent, + 'Other' + ) + // Subcategory precedence: valid source value that belongs to the resolved category > reservation + // recommendation default ('Commitment Discount Coverage') > 'Other' + | extend x_RecommendationSubcategory = case( + isnotempty(tmp_Subcategory) and tmp_SubcategoryParent == x_RecommendationCategory, tmp_Subcategory, + x_SourceType == 'ReservationRecommendations', 'Commitment Discount Coverage', + 'Other' + ) + // | project ProviderName, ResourceId = tolower(ResourceId), // Force lowercase for consistent grouping/filtering @@ -472,7 +564,8 @@ Recommendations_transform_v1_4() x_EffectiveCostBefore = coalesce(x_EffectiveCostBefore, CostWithNoReservedInstances), x_EffectiveCostSavings = coalesce(x_EffectiveCostSavings, NetSavings, toreal(x_RecommendationDetails.x_SavingsAmount), toreal(x_RecommendationDetails.x_AnnualSavingsAmount) / 12), x_IngestionTime, - x_RecommendationCategory, // TODO: Set for reservation recommendations + x_RecommendationCategory, + x_RecommendationSubcategory, x_RecommendationDate, x_RecommendationDescription = coalesce(x_RecommendationDescription, tostring(x_RecommendationDetails.x_RecommendationSolution), tostring(x_RecommendationDetails.x_RecommendationSubCategory)), x_RecommendationDetails, @@ -497,6 +590,7 @@ Recommendations_transform_v1_4() x_EffectiveCostSavings: real, x_IngestionTime: datetime, x_RecommendationCategory: string, + x_RecommendationSubcategory: string, x_RecommendationDate: datetime, x_RecommendationDescription: string, x_RecommendationDetails: dynamic, diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json index 31c5d2b6e..fb9dc0d3d 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessAppGateways.json @@ -1,7 +1,7 @@ { "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| 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= tostring(bag_pack('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", + "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| 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= tostring(bag_pack('backendIPCount', backendIPCount, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Review and remove this resource if not needed', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json index 0ca3f1b14..cd8117620 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json @@ -1,7 +1,7 @@ { "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= tostring(bag_pack('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", + "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= tostring(bag_pack('SKUName', SKUName, 'SKUTier', SKUTier, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Review and remove this resource if not needed', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicLoadBalancers.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicLoadBalancers.json index 7d0c667ae..98114e2b4 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicLoadBalancers.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicLoadBalancers.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/loadbalancers' | where sku.name =~ 'Basic' | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-basicLoadBalancer'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Load balancer is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to Standard SKU load balancer', 'x_RecommendationTypeId', 'a2b3c4d5-e6f7-8a9b-0c1d-2e3f4a5b6c7d', '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-BasicLoadBalancers", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/loadbalancers' | where sku.name =~ 'Basic' | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-basicLoadBalancer'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Load balancer is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to Standard SKU load balancer', 'x_RecommendationSubcategory', 'SKU Modernization', 'x_RecommendationTypeId', 'a2b3c4d5-e6f7-8a9b-0c1d-2e3f4a5b6c7d', '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-BasicLoadBalancers", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicPublicIPs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicPublicIPs.json index 677a4474e..a948ecfe6 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicPublicIPs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicPublicIPs.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/publicipaddresses' | where sku.name =~ 'Basic' | extend Location=location, AllocationMethod=tostring(properties.publicIPAllocationMethod), IpAddress=tostring(properties.ipAddress) | project id, name, resourceGroup, subscriptionId, Location, AllocationMethod, IpAddress, type | project x_RecommendationId=strcat(tolower(id),'-basicPublicIP'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Public IP is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('AllocationMethod', AllocationMethod, 'IpAddress', IpAddress, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to Standard SKU public IP', 'x_RecommendationTypeId', 'f1a2b3c4-d5e6-7f8a-9b0c-1d2e3f4a5b6c', '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-BasicPublicIPs", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/publicipaddresses' | where sku.name =~ 'Basic' | extend Location=location, AllocationMethod=tostring(properties.publicIPAllocationMethod), IpAddress=tostring(properties.ipAddress) | project id, name, resourceGroup, subscriptionId, Location, AllocationMethod, IpAddress, type | project x_RecommendationId=strcat(tolower(id),'-basicPublicIP'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Public IP is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('AllocationMethod', AllocationMethod, 'IpAddress', IpAddress, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to Standard SKU public IP', 'x_RecommendationSubcategory', 'SKU Modernization', 'x_RecommendationTypeId', 'f1a2b3c4-d5e6-7f8a-9b0c-1d2e3f4a5b6c', '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-BasicPublicIPs", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-ClassicAppGateways.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-ClassicAppGateways.json index 55acbae59..5e14c7925 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-ClassicAppGateways.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-ClassicAppGateways.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/applicationgateways' | where tostring(properties.sku.tier) in~ ('Standard', 'WAF') | extend Location=location, SKUName=tostring(properties.sku.name), SKUTier=tostring(properties.sku.tier), SKUCapacity=tostring(properties.sku.capacity) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, SKUCapacity, type | project x_RecommendationId=strcat(tolower(id),'-classicAppGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Application Gateway is using the retiring v1 SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'SKUTier', SKUTier, 'SKUCapacity', SKUCapacity, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Migrate to Application Gateway v2 (Standard_v2 or WAF_v2)', 'x_RecommendationTypeId', 'b3c4d5e6-f7a8-9b0c-1d2e-3f4a5b6c7d8e', '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-ClassicAppGateways", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/applicationgateways' | where tostring(properties.sku.tier) in~ ('Standard', 'WAF') | extend Location=location, SKUName=tostring(properties.sku.name), SKUTier=tostring(properties.sku.tier), SKUCapacity=tostring(properties.sku.capacity) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, SKUCapacity, type | project x_RecommendationId=strcat(tolower(id),'-classicAppGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Application Gateway is using the retiring v1 SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'SKUTier', SKUTier, 'SKUCapacity', SKUCapacity, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Migrate to Application Gateway v2 (Standard_v2 or WAF_v2)', 'x_RecommendationSubcategory', 'SKU Modernization', 'x_RecommendationTypeId', 'b3c4d5e6-f7a8-9b0c-1d2e-3f4a5b6c7d8e', '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-ClassicAppGateways", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyAppServicePlans.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyAppServicePlans.json index 3306b29fb..5e75d4865 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyAppServicePlans.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyAppServicePlans.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.web/serverfarms' | where toint(properties.numberOfSites) == 0 | where sku.tier !~ 'Free' | extend Location=location, SKUName=sku.name, SKUTier=sku.tier, SKUCapacity=tostring(sku.capacity) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, SKUCapacity, type | project x_RecommendationId=strcat(tolower(id),'-emptyAppServicePlan'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='App Service plan has no apps hosted on it', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'SKUTier', SKUTier, 'SKUCapacity', SKUCapacity, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the empty App Service plan or deploy apps to it', 'x_RecommendationTypeId', 'd7b1c874-5e1a-4e3b-9f0a-1c2d3e4f5a6b', '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", + "query": "resources | where type =~ 'microsoft.web/serverfarms' | where toint(properties.numberOfSites) == 0 | where sku.tier !~ 'Free' | extend Location=location, SKUName=sku.name, SKUTier=sku.tier, SKUCapacity=tostring(sku.capacity) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, SKUCapacity, type | project x_RecommendationId=strcat(tolower(id),'-emptyAppServicePlan'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='App Service plan has no apps hosted on it', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'SKUTier', SKUTier, 'SKUCapacity', SKUCapacity, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the empty App Service plan or deploy apps to it', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'd7b1c874-5e1a-4e3b-9f0a-1c2d3e4f5a6b', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyNSGs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyNSGs.json index f5a9571e0..3724d5423 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyNSGs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyNSGs.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/networksecuritygroups' | where isnull(properties.networkInterfaces) and isnull(properties.subnets) | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-emptyNSG'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Network security group is not associated with any network interface or subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned network security group or associate it with a subnet or NIC', 'x_RecommendationTypeId', 'b9c0d1e2-f3a4-5b6c-7d8e-9f0a1b2c3d4e', '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-EmptyNSGs", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/networksecuritygroups' | where isnull(properties.networkInterfaces) and isnull(properties.subnets) | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-emptyNSG'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Network security group is not associated with any network interface or subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned network security group or associate it with a subnet or NIC', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'b9c0d1e2-f3a4-5b6c-7d8e-9f0a1b2c3d4e', '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-EmptyNSGs", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json index 07fc4725c..50b673408 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json @@ -1,7 +1,7 @@ { "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= tostring(bag_pack('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", + "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= tostring(bag_pack('skuName', skuName, 'skuTier', skuTier, 'skuCapacity', skuCapacity, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Review and remove this resource if not needed', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-IdleVNetGateways.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-IdleVNetGateways.json index 083551785..9c88c3ec6 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-IdleVNetGateways.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-IdleVNetGateways.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/virtualnetworkgateways' | extend gatewayType = tostring(properties.gatewayType) | where gatewayType in ('Vpn', 'ExpressRoute') | mv-expand ipConfig = properties.ipConfigurations | extend pipId = tostring(ipConfig.properties.publicIPAddress.id) | extend gwId = tolower(id) | join kind=leftouter ( resources | where type =~ 'microsoft.network/connections' | mv-expand gwRef = pack_array(properties.virtualNetworkGateway1.id, properties.virtualNetworkGateway2.id) | extend gwId = tolower(tostring(gwRef)) | summarize connectionCount = count() by gwId ) on gwId | where isnull(connectionCount) or connectionCount == 0 | extend Location=location, SKUName=tostring(properties.sku.name), SKUTier=tostring(properties.sku.tier) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, gatewayType, type | project x_RecommendationId=strcat(tolower(id),'-idleVNetGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual network gateway has no connections', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('GatewayType', gatewayType, 'SKUName', SKUName, 'SKUTier', SKUTier, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the idle virtual network gateway or add connections', 'x_RecommendationTypeId', 'a3b4c5d6-7e8f-9a0b-1c2d-3e4f5a6b7c8d', '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", + "query": "resources | where type =~ 'microsoft.network/virtualnetworkgateways' | extend gatewayType = tostring(properties.gatewayType) | where gatewayType in ('Vpn', 'ExpressRoute') | mv-expand ipConfig = properties.ipConfigurations | extend pipId = tostring(ipConfig.properties.publicIPAddress.id) | extend gwId = tolower(id) | join kind=leftouter ( resources | where type =~ 'microsoft.network/connections' | mv-expand gwRef = pack_array(properties.virtualNetworkGateway1.id, properties.virtualNetworkGateway2.id) | extend gwId = tolower(tostring(gwRef)) | summarize connectionCount = count() by gwId ) on gwId | where isnull(connectionCount) or connectionCount == 0 | extend Location=location, SKUName=tostring(properties.sku.name), SKUTier=tostring(properties.sku.tier) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, gatewayType, type | project x_RecommendationId=strcat(tolower(id),'-idleVNetGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual network gateway has no connections', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('GatewayType', gatewayType, 'SKUName', SKUName, 'SKUTier', SKUTier, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the idle virtual network gateway or add connections', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'a3b4c5d6-7e8f-9a0b-1c2d-3e4f5a6b7c8d', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyStorageAccounts.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyStorageAccounts.json index b48ed28fe..b97767a98 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyStorageAccounts.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyStorageAccounts.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.storage/storageaccounts' | where kind in~ ('Storage', 'BlobStorage') and parse_json(sku).name in~ ('Standard_LRS', 'Standard_GRS', 'Standard_ZRS', 'Standard_RAGRS', 'Standard_RAGZRS') | extend Location=location, AccessTier=tostring(properties.accessTier), Kind=tostring(kind) | project id, name, resourceGroup, subscriptionId, Location, Kind, AccessTier, type | project x_RecommendationId=strcat(tolower(id),'-legacyStorageAccount'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Storage account is using a legacy kind that is being retired', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Kind', Kind, 'AccessTier', AccessTier, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to General Purpose v2 (StorageV2) storage account', 'x_RecommendationTypeId', 'c4d5e6f7-a8b9-0c1d-2e3f-4a5b6c7d8e9f', '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-LegacyStorageAccounts", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.storage/storageaccounts' | where kind in~ ('Storage', 'BlobStorage') and parse_json(sku).name in~ ('Standard_LRS', 'Standard_GRS', 'Standard_ZRS', 'Standard_RAGRS', 'Standard_RAGZRS') | extend Location=location, AccessTier=tostring(properties.accessTier), Kind=tostring(kind) | project id, name, resourceGroup, subscriptionId, Location, Kind, AccessTier, type | project x_RecommendationId=strcat(tolower(id),'-legacyStorageAccount'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Storage account is using a legacy kind that is being retired', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Kind', Kind, 'AccessTier', AccessTier, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Upgrade to General Purpose v2 (StorageV2) storage account', 'x_RecommendationSubcategory', 'SKU Modernization', 'x_RecommendationTypeId', 'c4d5e6f7-a8b9-0c1d-2e3f-4a5b6c7d8e9f', '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-LegacyStorageAccounts", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json index 2f2137a94..e80c3569e 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-NonSpotAKSClusters.json @@ -1,11 +1,11 @@ { "dataset": "Recommendations", + "group": "spot", "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= tostring(bag_pack('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", + "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= tostring(bag_pack('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_RecommendationSubcategory', 'Spot Eligibility', '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", - "group": "spot", "type": "Microsoft-NonSpotAKSClusters", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-OrphanedNATGateways.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-OrphanedNATGateways.json index 535aadcc4..d4ce0e9d5 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-OrphanedNATGateways.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-OrphanedNATGateways.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/natgateways' | where isnull(properties.subnets) or array_length(properties.subnets) == 0 | extend Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-orphanedNATGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='NAT gateway is not associated with any subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned NAT gateway or associate it with a subnet', 'x_RecommendationTypeId', 'b4c5d6e7-8f9a-0b1c-2d3e-4f5a6b7c8d9e', '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", + "query": "resources | where type =~ 'microsoft.network/natgateways' | where isnull(properties.subnets) or array_length(properties.subnets) == 0 | extend Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-orphanedNATGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='NAT gateway is not associated with any subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('SKUName', SKUName, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned NAT gateway or associate it with a subnet', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'b4c5d6e7-8f9a-0b1c-2d3e-4f5a6b7c8d9e', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-PremiumSnapshots.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-PremiumSnapshots.json index d6f264914..1a1cf1432 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-PremiumSnapshots.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-PremiumSnapshots.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.compute/snapshots' | where sku.name =~ 'Premium_LRS' | extend DiskSizeGB=tostring(properties.diskSizeGB), TimeCreated=tostring(properties.timeCreated), Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, DiskSizeGB, TimeCreated, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-premiumSnapshot'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Snapshot is using Premium SSD storage instead of Standard', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('DiskSizeGB', DiskSizeGB, 'SKUName', SKUName, 'TimeCreated', TimeCreated, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Change snapshot storage type from Premium SSD to Standard HDD', 'x_RecommendationTypeId', 'e7f8a9b0-1c2d-3e4f-5a6b-7c8d9e0f1a2b', '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", + "query": "resources | where type =~ 'microsoft.compute/snapshots' | where sku.name =~ 'Premium_LRS' | extend DiskSizeGB=tostring(properties.diskSizeGB), TimeCreated=tostring(properties.timeCreated), Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, DiskSizeGB, TimeCreated, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-premiumSnapshot'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Snapshot is using Premium SSD storage instead of Standard', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('DiskSizeGB', DiskSizeGB, 'SKUName', SKUName, 'TimeCreated', TimeCreated, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Change snapshot storage type from Premium SSD to Standard HDD', 'x_RecommendationSubcategory', 'Storage Tiering', 'x_RecommendationTypeId', 'e7f8a9b0-1c2d-3e4f-5a6b-7c8d9e0f1a2b', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json index 1e4691147..a6b32985b 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json @@ -1,11 +1,11 @@ { "dataset": "Recommendations", + "group": "ahb", "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= tostring(bag_pack('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", + "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= tostring(bag_pack('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_RecommendationSubcategory', 'License Optimization', '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", - "group": "ahb", "type": "Microsoft-SQLVMsWithoutAHB", "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json index 4834d0271..9ec9ccfee 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.compute/virtualmachines' and tolower(tostring(properties.extended.instanceView.powerState.code)) =~ 'powerstate/stopped' | 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= tostring(bag_pack('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", + "query": "resources | where type =~ 'microsoft.compute/virtualmachines' and tolower(tostring(properties.extended.instanceView.powerState.code)) =~ 'powerstate/stopped' | 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= tostring(bag_pack('PowerState', PowerState, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Deallocate the virtual machine to ensure it does not incur in compute costs', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json index 5e1843b3b..7f123534b 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/ddosprotectionplans' | where isnull(properties.virtualNetworks) or array_length(properties.virtualNetworks) == 0 | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-unassociatedDDoSPlan'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='DDoS protection plan is not associated with any virtual network', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the DDoS protection plan or associate it with virtual networks', 'x_RecommendationTypeId', 'c5d6e7f8-9a0b-1c2d-3e4f-5a6b7c8d9e0f', '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", + "query": "resources | where type =~ 'microsoft.network/ddosprotectionplans' | where isnull(properties.virtualNetworks) or array_length(properties.virtualNetworks) == 0 | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-unassociatedDDoSPlan'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='DDoS protection plan is not associated with any virtual network', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the DDoS protection plan or associate it with virtual networks', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'c5d6e7f8-9a0b-1c2d-3e4f-5a6b7c8d9e0f', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json index 939aec12c..1541e4f58 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json @@ -1,7 +1,7 @@ { "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= tostring(bag_pack('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", + "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= tostring(bag_pack('DiskSizeGB', DiskSizeGB, 'SKUName', SKUName, 'SKUTier', SKUTier, 'Location', Location, 'TimeCreated', TimeCreated, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Remove or downgrade the unattached disk', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedNICs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedNICs.json index d838d8cc6..340f1ca71 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedNICs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedNICs.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/networkinterfaces' | where isnull(properties.virtualMachine) and isnull(properties.privateEndpoint) | extend Location=location, PrivateIP=tostring(properties.ipConfigurations[0].properties.privateIPAddress) | project id, name, resourceGroup, subscriptionId, Location, PrivateIP, type | project x_RecommendationId=strcat(tolower(id),'-unattachedNIC'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Network interface is not attached to any virtual machine or private endpoint', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('PrivateIP', PrivateIP, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned network interface', 'x_RecommendationTypeId', 'a8b9c0d1-e2f3-4a5b-6c7d-8e9f0a1b2c3d', '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-UnattachedNICs", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/networkinterfaces' | where isnull(properties.virtualMachine) and isnull(properties.privateEndpoint) | extend Location=location, PrivateIP=tostring(properties.ipConfigurations[0].properties.privateIPAddress) | project id, name, resourceGroup, subscriptionId, Location, PrivateIP, type | project x_RecommendationId=strcat(tolower(id),'-unattachedNIC'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Network interface is not attached to any virtual machine or private endpoint', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('PrivateIP', PrivateIP, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Delete the orphaned network interface', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'a8b9c0d1-e2f3-4a5b-6c7d-8e9f0a1b2c3d', '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-UnattachedNICs", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json index 67cc597b4..336b6f1ba 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedPublicIPs.json @@ -1,7 +1,7 @@ { "dataset": "Recommendations", "provider": "Microsoft", - "query": "resources | where type =~ 'Microsoft.Network/publicIPAddresses' and isempty(properties.ipConfiguration) and isempty(properties.natGateway) | 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', ResourceId = tolower(PublicIpId), ResourceName=tolower(name), x_RecommendationDetails= tostring(bag_pack('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", + "query": "resources | where type =~ 'Microsoft.Network/publicIPAddresses' and isempty(properties.ipConfiguration) and isempty(properties.natGateway) | 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', ResourceId = tolower(PublicIpId), ResourceName=tolower(name), x_RecommendationDetails= tostring(bag_pack('SKUName', SKUName, 'AllocationMethod', AllocationMethod, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Review and remove this resource if not needed', 'x_RecommendationSubcategory', 'Idle Resources', '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", diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnmanagedDisks.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnmanagedDisks.json index 36839eddf..d880dbd12 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnmanagedDisks.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnmanagedDisks.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.compute/virtualmachines' | where isnull(properties.storageProfile.osDisk.managedDisk) | extend Location=location, VMSize=tostring(properties.hardwareProfile.vmSize) | project id, name, resourceGroup, subscriptionId, Location, VMSize, type | project x_RecommendationId=strcat(tolower(id),'-unmanagedDisks'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual machine is using unmanaged disks that are being retired', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('VMSize', VMSize, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Migrate to managed disks', 'x_RecommendationTypeId', 'd5e6f7a8-b9c0-1d2e-3f4a-5b6c7d8e9f0a', '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-UnmanagedDisks", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.compute/virtualmachines' | where isnull(properties.storageProfile.osDisk.managedDisk) | extend Location=location, VMSize=tostring(properties.hardwareProfile.vmSize) | project id, name, resourceGroup, subscriptionId, Location, VMSize, type | project x_RecommendationId=strcat(tolower(id),'-unmanagedDisks'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Virtual machine is using unmanaged disks that are being retired', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('VMSize', VMSize, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Migrate to managed disks', 'x_RecommendationSubcategory', 'SKU Modernization', 'x_RecommendationTypeId', 'd5e6f7a8-b9c0-1d2e-3f4a-5b6c7d8e9f0a', '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-UnmanagedDisks", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json index ed1a72602..3c82d969e 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json @@ -1,10 +1,10 @@ { - "dataset": "Recommendations", - "provider": "Microsoft", - "query": "resources | where type =~ 'microsoft.network/expressroutecircuits' | where tostring(properties.serviceProviderProvisioningState) != 'Provisioned' | extend CircuitState=tostring(properties.circuitProvisioningState), ProviderState=tostring(properties.serviceProviderProvisioningState), ServiceProvider=tostring(properties.serviceProviderProperties.serviceProviderName), BandwidthMbps=tostring(properties.serviceProviderProperties.bandwidthInMbps), SKUName=tostring(sku.name), SKUTier=tostring(sku.tier), SKUFamily=tostring(sku.family), Location=location | project id, name, resourceGroup, subscriptionId, Location, CircuitState, ProviderState, ServiceProvider, BandwidthMbps, SKUName, SKUTier, SKUFamily, type | project x_RecommendationId=strcat(tolower(id),'-unprovisionedExpressRoute'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='ExpressRoute circuit is not in provisioned state', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('CircuitState', CircuitState, 'ProviderState', ProviderState, 'ServiceProvider', ServiceProvider, 'BandwidthMbps', BandwidthMbps, 'SKUName', SKUName, 'SKUTier', SKUTier, 'SKUFamily', SKUFamily, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Complete provisioning or delete the ExpressRoute circuit', 'x_RecommendationTypeId', 'd6e7f8a9-0b1c-2d3e-4f5a-6b7c8d9e0f1a', '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-UnprovisionedExpressRouteCircuits", - "version": "1.0" + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "resources | where type =~ 'microsoft.network/expressroutecircuits' | where tostring(properties.serviceProviderProvisioningState) != 'Provisioned' | extend CircuitState=tostring(properties.circuitProvisioningState), ProviderState=tostring(properties.serviceProviderProvisioningState), ServiceProvider=tostring(properties.serviceProviderProperties.serviceProviderName), BandwidthMbps=tostring(properties.serviceProviderProperties.bandwidthInMbps), SKUName=tostring(sku.name), SKUTier=tostring(sku.tier), SKUFamily=tostring(sku.family), Location=location | project id, name, resourceGroup, subscriptionId, Location, CircuitState, ProviderState, ServiceProvider, BandwidthMbps, SKUName, SKUTier, SKUFamily, type | project x_RecommendationId=strcat(tolower(id),'-unprovisionedExpressRoute'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='ExpressRoute circuit is not in provisioned state', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails = tostring(bag_pack('CircuitState', CircuitState, 'ProviderState', ProviderState, 'ServiceProvider', ServiceProvider, 'BandwidthMbps', BandwidthMbps, 'SKUName', SKUName, 'SKUTier', SKUTier, 'SKUFamily', SKUFamily, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Complete provisioning or delete the ExpressRoute circuit', 'x_RecommendationSubcategory', 'Idle Resources', 'x_RecommendationTypeId', 'd6e7f8a9-0b1c-2d3e-4f5a-6b7c8d9e0f1a', '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-UnprovisionedExpressRouteCircuits", + "version": "1.0" } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json index 603771ebb..ff62a7e72 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-VMsWithoutAHB.json @@ -1,11 +1,11 @@ { "dataset": "Recommendations", + "group": "ahb", "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.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.storageProfile.imageReference.publisher) !in~ ('microsoftwindowsdesktop','microsoftvisualstudio') and tostring(properties.virtualMachineProfile.licenseType) !startswith 'Windows') or (tostring(properties.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.storageProfile.imageReference.publisher) !in~ ('microsoftwindowsdesktop','microsoftvisualstudio') and tostring(properties.licenseType) !startswith 'Windows') | 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= tostring(bag_pack('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", + "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.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.storageProfile.imageReference.publisher) !in~ ('microsoftwindowsdesktop','microsoftvisualstudio') and tostring(properties.virtualMachineProfile.licenseType) !startswith 'Windows') or (tostring(properties.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.storageProfile.imageReference.publisher) !in~ ('microsoftwindowsdesktop','microsoftvisualstudio') and tostring(properties.licenseType) !startswith 'Windows') | 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= tostring(bag_pack('VMSku', VMSku, 'CheckAHBWindows', CheckAHBWindows, 'ActualCores', ActualCores, 'Location', Location, 'x_RecommendationProvider', 'FinOps hubs', 'x_RecommendationSolution', 'Review the virtual machine licensing option', 'x_RecommendationSubcategory', 'License Optimization', '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", - "group": "ahb", "type": "Microsoft-VMsWithoutAHB", "version": "1.0" }