From 2a88b02d542d277fe575ffc1d66b45f7d1c9411e Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Wed, 24 Jun 2026 16:48:19 -0700 Subject: [PATCH 1/4] Add x_RecommendationCategory and x_RecommendationSubcategory enrichments Adds two hub-toolkit enrichment columns to the Recommendations dataset in the FOCUS 1.4 hub schema, normalized to a fixed hub taxonomy so reports can group recommendations consistently across providers. - IngestionSetup_v1_4.kql: normalize x_RecommendationCategory into the 5 top-level values (Cost, Operational Excellence, Performance, Reliability, Security); map Azure Advisor's HighAvailability -> Reliability; derive x_RecommendationSubcategory from the documented taxonomy with 'Other' as the fallback. Add both columns to Recommendations_final_v1_4. - HubSetup_v1_4.kql: include both columns in the Recommendations_v1_4() project list. - data-model.md: document the allowed values for both columns and the normalization behavior. - HubsFocusSchemas.Tests.ps1: assert columns exist in the final table, the transform projects them, and the case() expression covers all 5 top-level categories. - changelog.md: note the new enrichment columns under FinOps hubs v15.0.0. Co-Authored-By: Michael Flanakin Co-Authored-By: Claude Opus 4.7 --- docs-mslearn/toolkit/changelog.md | 3 +- docs-mslearn/toolkit/hubs/data-model.md | 55 ++++++++++++++++++- .../Tests/Unit/HubsFocusSchemas.Tests.ps1 | 50 +++++++++++++++++ .../Analytics/scripts/HubSetup_v1_4.kql | 1 + .../Analytics/scripts/IngestionSetup_v1_4.kql | 55 ++++++++++++++++++- 5 files changed, 160 insertions(+), 4 deletions(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 4c50c8c1d..97d8f7012 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: 05/27/2026 +ms.date: 06/24/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -39,6 +39,7 @@ The following section lists features and enhancements that are currently in deve - **Added** - Added FOCUS 1.4 hub schema (`v1_4`). New cost and usage columns: `AllocatedMethodId`, `AllocatedMethodDetails`, `AllocatedResourceId`, `AllocatedResourceName`, `AllocatedTags`, `ContractApplied`, `ServiceProviderName`, `HostProviderName`, `CommitmentProgramEligibilityDetails`, `InvoiceDetailId`, and 12 `ContractCommitment*` per-row columns (`ContractCommitmentBenefitCategory`, `ContractCommitmentCreated`, `ContractCommitmentDiscountPercentage`, `ContractCommitmentDurationType`, `ContractCommitmentFulfillmentInterval`, `ContractCommitmentLastUpdated`, `ContractCommitmentLifecycleStatus`, `ContractCommitmentModel`, `ContractCommitmentOfferCategory`, `ContractCommitmentPaymentInterval`, `ContractCommitmentPaymentModel`, `ContractCommitmentPaymentUpfrontPercentage`). Removes deprecated `ProviderName` and `PublisherName` from the schema (raw tables keep them for back compat). Three new supplemental datasets: `ContractCommitments` (28 columns), `BillingPeriods` (6 columns), and `InvoiceDetails` (22 columns) ([#2120](https://github.com/microsoft/finops-toolkit/issues/2120)). - Added unversioned `ContractCommitments()`, `BillingPeriods()`, and `InvoiceDetails()` functions aliasing to their `_v1_4` counterparts. + - Added `x_RecommendationCategory` and `x_RecommendationSubcategory` enrichment columns to the `Recommendations` dataset, normalized to a fixed hub taxonomy (Cost, Operational Excellence, Performance, Reliability, Security). 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. - Refreshed the canonical "add a new FOCUS version" procedure in [src/templates/finops-hub/docs/README.md](https://github.com/microsoft/finops-toolkit/tree/dev/src/templates/finops-hub/docs/README.md) with multi-version-cycle, plugin, and changelog steps. diff --git a/docs-mslearn/toolkit/hubs/data-model.md b/docs-mslearn/toolkit/hubs/data-model.md index e06a19519..f8a927f66 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: 06/24/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. | @@ -749,6 +750,56 @@ Columns in the **Recommendations** managed dataset include: 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_RecommendationCategory** and **x_RecommendationSubcategory** columns classify each recommendation against a fixed hub taxonomy so reports can group across providers consistently. Source data is normalized on ingestion: source category values (for example, Azure Advisor's `HighAvailability` → `Reliability`) are mapped to the allowed values below. Unmapped category values become an empty string and unmapped subcategory values become `Other`. + +Allowed **x_RecommendationCategory** values: `Cost`, `Operational Excellence`, `Performance`, `Reliability`, `Security`. + +Allowed **x_RecommendationSubcategory** values, grouped by parent category: + +- Cost + - Autoscaling + - Commitment Discount Coverage + - Commitment Discount Utilization + - Idle Resources (includes unused capacity and orphaned resources) + - Low Utilization + - Negotiated Discount Enablement + - Region Placement + - Right-Sizing + - Scheduling + - Service Selection and Architecture + - SKU Modernization + - Spot Eligibility +- Operational Excellence + - Automation and Process + - Best Practices + - Governance and Policy + - Observability + - Operational Hygiene + - Quotas and Limits + - Resource Consistency +- Performance + - Performance Tuning + - Resource Configuration + - Resource Sizing + - Scaling and Capacity + - Throughput and Latency + - Workload Placement +- Reliability + - Backup Configuration + - Capacity + - Disaster Recovery + - High Availability + - Service Retirement + - Zone Resiliency +- Security + - Compliance Alignment + - Data Protection + - Identity & Access + - Network Security + - Threat Detection + - Vulnerability Exposure +- Other (fallback when no specific subcategory applies) +
## Regions table diff --git a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 index 02f4d3584..c8bd5fcb6 100644 --- a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 @@ -229,6 +229,56 @@ Describe 'HubsFocusSchemas' { ) { $hubFiles.v1_4 | Should -Match "database\('Ingestion'\)\.$_" } + + It 'Recommendations_v1_4 projects <_>' -ForEach @( + 'x_RecommendationCategory', 'x_RecommendationSubcategory' + ) { + $script:recommendationsV14Block = if ($hubFiles.v1_4 -match '(?ms)Recommendations_v1_4\(\)\s*\{(.*?)\n\}') { $Matches[1] } else { '' } + $recommendationsV14Block | Should -Match "(?m)^\s+$_\s*,?\s*$" + } + } + + Context 'Recommendations enrichment in IngestionSetup_v1_4.kql' { + + 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 { '' } + } + + 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_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 "HighAvailability" + $recommendationsTransformV14 | Should -Match "'Reliability'" + } + + It 'Recommendations_transform_v1_4 falls back subcategory to Other' { + $recommendationsTransformV14 | Should -Match "'Other'" + } } Context 'HubSetup_Latest.kql aliases' { 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 fc67110b2..b7b232d05 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 @@ -582,6 +582,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.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4.kql index f0386ff32..823cf44ad 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_4.kql @@ -2084,6 +2084,57 @@ Recommendations_transform_v1_4() // Set ResourceType display name from x_ResourceType code | extend ResourceType = coalesce(ResourceType, tostring(resource_type(tmp_ResourceType).SingularDisplayName), tmp_ResourceType) // + // Normalize x_RecommendationCategory into the hub taxonomy (Cost, Operational Excellence, Performance, Reliability, Security) + // Source: Azure Advisor Category values are PascalCase (Cost, HighAvailability, OperationalExcellence, Performance, Security). + // Reservation recommendations from Cost Management have no category set; default to Cost. + | extend x_RecommendationCategory = tostring(case( + // Empty / null: default reservation recommendations to Cost; leave others empty for unknown + isempty(x_RecommendationCategory), iff(x_SourceType == 'ReservationRecommendations', 'Cost', ''), + // 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 =~ 'Security', 'Security', + // Unknown source value: leave empty + '' + )) + // + // Derive x_RecommendationSubcategory from the hub taxonomy. Default to 'Other' so every row has a value. + // Reservation recommendations always map to 'Commitment Discount Coverage'. For other sources without a + // deterministic mapping we fall back to 'Other'. Source value (if present) comes from x_RecommendationDetails. + | extend tmp_Subcategory = tostring(x_RecommendationDetails.x_RecommendationSubcategory) + | extend x_RecommendationSubcategory = tostring(case( + // Already a documented subcategory value passes through + tmp_Subcategory in ( + // Cost + 'Commitment Discount Coverage', 'Negotiated Discount Enablement', 'Spot Eligibility', + 'Region Placement', 'Service Selection and Architecture', 'Right-Sizing', + 'SKU Modernization', 'Autoscaling', 'Commitment Discount Utilization', 'Scheduling', + 'Low Utilization', 'Idle Resources', + // Operational Excellence + 'Automation and Process', 'Best Practices', 'Governance and Policy', 'Observability', + 'Operational Hygiene', 'Quotas and Limits', 'Resource Consistency', + // Performance + 'Performance Tuning', 'Resource Configuration', 'Resource Sizing', + 'Scaling and Capacity', 'Throughput and Latency', 'Workload Placement', + // Reliability + 'Backup Configuration', 'Capacity', 'Disaster Recovery', 'High Availability', + 'Service Retirement', 'Zone Resiliency', + // Security + 'Compliance Alignment', 'Data Protection', 'Identity & Access', 'Network Security', + 'Threat Detection', 'Vulnerability Exposure', + // Fallback + 'Other' + ), tmp_Subcategory, + // Reservation recommendations are always commitment discount coverage + x_SourceType == 'ReservationRecommendations', 'Commitment Discount Coverage', + // No deterministic mapping yet for Advisor / custom recommendations + 'Other' + )) + // | project ProviderName, ResourceId = tolower(ResourceId), // Force lowercase for consistent grouping/filtering @@ -2095,7 +2146,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, @@ -2120,6 +2172,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, From a6c91087e2c5db8574072d7119e53e7ac6582a90 Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Wed, 15 Jul 2026 14:12:35 -0700 Subject: [PATCH 2/4] feat(hubs): map hub-native recommendations to taxonomy subcategories and enforce category coherence Addresses the W9 review feedback on the recommendation taxonomy: - Declare x_RecommendationSubcategory in all 22 single-purpose hub-native Resource Graph queries (Idle Resources, SKU Modernization, Spot Eligibility, License Optimization, Storage Tiering) so they no longer fall back to 'Other'. AdvisorCost is per-row heterogeneous and keeps the transform fallback. - Replace the flat subcategory allow-list in Recommendations_transform_v1_4 with a SubcategoryTaxonomy datatable of (subcategory, parent) pairs, and enforce coherence: unknown categories are backfilled from a valid subcategory's parent, and a subcategory only passes through when its parent matches the resolved category. - Make defaults symmetric and deliberate: both columns always have a value and unmapped values become 'Other' for both. - Taxonomy hygiene: add License Optimization and Storage Tiering (both used by hub queries today); rename Identity & Access -> Identity and Access and Negotiated Discount Enablement -> Negotiated Discounts; keep unused commitment capacity under Commitment Discount Utilization instead of overloading Idle Resources. - Document the canonical taxonomy contract, source-value mapping table, normalization rules, and cross-category tie-breakers in data-model.md; document subcategory guidance for custom queries in configure-recommendations.md. - Rework the taxonomy test context: canonical taxonomy as test source of truth validated against both the KQL datatable and the docs, coherence rule assertions, and per-query subcategory validation for all 22 hub query files. Co-Authored-By: Claude Fable 5 --- .../toolkit/hubs/configure-recommendations.md | 6 +- docs-mslearn/toolkit/hubs/data-model.md | 55 +++++--- .../Tests/Unit/HubsFocusSchemas.Tests.ps1 | 132 ++++++++++++++++-- .../scripts/IngestionSetup_v1_4_Datasets.kql | 113 ++++++++++----- ...ions-Microsoft-BackendlessAppGateways.json | 2 +- ...ns-Microsoft-BackendlessLoadBalancers.json | 2 +- ...ndations-Microsoft-BasicLoadBalancers.json | 16 +-- ...ommendations-Microsoft-BasicPublicIPs.json | 16 +-- ...ndations-Microsoft-ClassicAppGateways.json | 16 +-- ...ations-Microsoft-EmptyAppServicePlans.json | 2 +- .../Recommendations-Microsoft-EmptyNSGs.json | 16 +-- ...ations-Microsoft-EmptySQLElasticPools.json | 2 +- ...mendations-Microsoft-IdleVNetGateways.json | 2 +- ...tions-Microsoft-LegacyStorageAccounts.json | 16 +-- ...ndations-Microsoft-NonSpotAKSClusters.json | 4 +- ...dations-Microsoft-OrphanedNATGateways.json | 2 +- ...mendations-Microsoft-PremiumSnapshots.json | 2 +- ...mendations-Microsoft-SQLVMsWithoutAHB.json | 4 +- .../Recommendations-Microsoft-StoppedVMs.json | 2 +- ...tions-Microsoft-UnassociatedDDoSPlans.json | 2 +- ...mmendations-Microsoft-UnattachedDisks.json | 2 +- ...ommendations-Microsoft-UnattachedNICs.json | 16 +-- ...dations-Microsoft-UnattachedPublicIPs.json | 2 +- ...ommendations-Microsoft-UnmanagedDisks.json | 16 +-- ...oft-UnprovisionedExpressRouteCircuits.json | 16 +-- ...commendations-Microsoft-VMsWithoutAHB.json | 4 +- 26 files changed, 320 insertions(+), 148 deletions(-) diff --git a/docs-mslearn/toolkit/hubs/configure-recommendations.md b/docs-mslearn/toolkit/hubs/configure-recommendations.md index aa7644600..903806dc0 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/15/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 f8a927f66..f219fdd56 100644 --- a/docs-mslearn/toolkit/hubs/data-model.md +++ b/docs-mslearn/toolkit/hubs/data-model.md @@ -748,45 +748,66 @@ 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. Source data is normalized on ingestion: source category values (for example, Azure Advisor's `HighAvailability` → `Reliability`) are mapped to the allowed values below. Unmapped category values become an empty string and unmapped subcategory values become `Other`. +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. -Allowed **x_RecommendationCategory** values: `Cost`, `Operational Excellence`, `Performance`, `Reliability`, `Security`. +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 - - Commitment Discount Utilization - - Idle Resources (includes unused capacity and orphaned resources) - - Low Utilization - - Negotiated Discount Enablement + - 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 + - Right-Sizing (size down to reduce cost; sizing up to meet demand is Resource Sizing under Performance) - Scheduling - Service Selection and Architecture - - SKU Modernization + - 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 + - Quotas and Limits (quota recommendations always land here, even when quota exhaustion has reliability consequences) - Resource Consistency - Performance - Performance Tuning - Resource Configuration - - Resource Sizing - - Scaling and Capacity + - 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 - - Capacity + - 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 @@ -794,12 +815,14 @@ Allowed **x_RecommendationSubcategory** values, grouped by parent category: - Security - Compliance Alignment - Data Protection - - Identity & Access + - 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). +
## Regions table diff --git a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 index 1356d3370..4a8ff00b7 100644 --- a/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubsFocusSchemas.Tests.ps1 @@ -479,20 +479,82 @@ Describe 'HubsFocusSchemas' { $content | Should -Not -Match '(?' -ForEach @( - 'x_RecommendationCategory', 'x_RecommendationSubcategory' - ) { - $script:recommendationsV14Block = if ($hubFiles.v1_4 -match '(?ms)Recommendations_v1_4\(\)\s*\{(.*?)\n\}') { $Matches[1] } else { '' } - $recommendationsV14Block | Should -Match "(?m)^\s+$_\s*,?\s*$" - } } - Context 'Recommendations enrichment in IngestionSetup_v1_4.kql' { + 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' { @@ -515,6 +577,13 @@ Describe 'HubsFocusSchemas' { $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'" ) { @@ -522,12 +591,51 @@ Describe 'HubsFocusSchemas' { } It 'Recommendations_transform_v1_4 maps Advisor HighAvailability to Reliability' { - $recommendationsTransformV14 | Should -Match "HighAvailability" - $recommendationsTransformV14 | Should -Match "'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 'Recommendations_transform_v1_4 falls back subcategory to Other' { - $recommendationsTransformV14 | Should -Match "'Other'" + 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'" } } 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 0b7b47cb6..81e7d11a2 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 @@ -387,6 +387,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() // @@ -457,12 +507,18 @@ Recommendations_transform_v1_4() // Set ResourceType display name from x_ResourceType code | extend ResourceType = coalesce(ResourceType, tostring(resource_type(tmp_ResourceType).SingularDisplayName), tmp_ResourceType) // - // Normalize x_RecommendationCategory into the hub taxonomy (Cost, Operational Excellence, Performance, Reliability, Security) - // Source: Azure Advisor Category values are PascalCase (Cost, HighAvailability, OperationalExcellence, Performance, Security). - // Reservation recommendations from Cost Management have no category set; default to Cost. + // 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( - // Empty / null: default reservation recommendations to Cost; leave others empty for unknown - isempty(x_RecommendationCategory), iff(x_SourceType == 'ReservationRecommendations', 'Cost', ''), // Already normalized values pass through x_RecommendationCategory in ('Cost', 'Operational Excellence', 'Performance', 'Reliability', 'Security'), x_RecommendationCategory, // Azure Advisor PascalCase values @@ -470,43 +526,28 @@ Recommendations_transform_v1_4() x_RecommendationCategory =~ 'HighAvailability', 'Reliability', x_RecommendationCategory =~ 'OperationalExcellence', 'Operational Excellence', x_RecommendationCategory =~ 'Performance', 'Performance', + x_RecommendationCategory =~ 'Reliability', 'Reliability', x_RecommendationCategory =~ 'Security', 'Security', - // Unknown source value: leave empty + // 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 '' )) - // - // Derive x_RecommendationSubcategory from the hub taxonomy. Default to 'Other' so every row has a value. - // Reservation recommendations always map to 'Commitment Discount Coverage'. For other sources without a - // deterministic mapping we fall back to 'Other'. Source value (if present) comes from x_RecommendationDetails. | extend tmp_Subcategory = tostring(x_RecommendationDetails.x_RecommendationSubcategory) - | extend x_RecommendationSubcategory = tostring(case( - // Already a documented subcategory value passes through - tmp_Subcategory in ( - // Cost - 'Commitment Discount Coverage', 'Negotiated Discount Enablement', 'Spot Eligibility', - 'Region Placement', 'Service Selection and Architecture', 'Right-Sizing', - 'SKU Modernization', 'Autoscaling', 'Commitment Discount Utilization', 'Scheduling', - 'Low Utilization', 'Idle Resources', - // Operational Excellence - 'Automation and Process', 'Best Practices', 'Governance and Policy', 'Observability', - 'Operational Hygiene', 'Quotas and Limits', 'Resource Consistency', - // Performance - 'Performance Tuning', 'Resource Configuration', 'Resource Sizing', - 'Scaling and Capacity', 'Throughput and Latency', 'Workload Placement', - // Reliability - 'Backup Configuration', 'Capacity', 'Disaster Recovery', 'High Availability', - 'Service Retirement', 'Zone Resiliency', - // Security - 'Compliance Alignment', 'Data Protection', 'Identity & Access', 'Network Security', - 'Threat Detection', 'Vulnerability Exposure', - // Fallback - 'Other' - ), tmp_Subcategory, - // Reservation recommendations are always commitment discount coverage + | 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', - // No deterministic mapping yet for Advisor / custom recommendations 'Other' - )) + ) // | project ProviderName, 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" } From 47b406ed48a476a286249fa80c2b2c16b08b7b2e Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Wed, 15 Jul 2026 14:15:04 -0700 Subject: [PATCH 3/4] docs: update ms.date for modified docs-mslearn files Co-Authored-By: Claude Fable 5 --- docs-mslearn/toolkit/hubs/data-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-mslearn/toolkit/hubs/data-model.md b/docs-mslearn/toolkit/hubs/data-model.md index f219fdd56..6d5c9d5d3 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: 06/24/2026 +ms.date: 07/15/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit From 987d06b636d2a362ef8f67997c5718c03a708591 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Jul 2026 00:13:01 +0000 Subject: [PATCH 4/4] chore: Update ms.date in docs-mslearn files --- docs-mslearn/toolkit/changelog.md | 2 +- docs-mslearn/toolkit/hubs/configure-recommendations.md | 2 +- docs-mslearn/toolkit/hubs/data-model.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 9c5245436..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 diff --git a/docs-mslearn/toolkit/hubs/configure-recommendations.md b/docs-mslearn/toolkit/hubs/configure-recommendations.md index 903806dc0..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: 07/15/2026 +ms.date: 07/16/2026 ms.topic: how-to ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/hubs/data-model.md b/docs-mslearn/toolkit/hubs/data-model.md index 6d5c9d5d3..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: 07/15/2026 +ms.date: 07/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit