Automate and manage FinOps solutions and capabilities.
🌐 Open data
Query FinOps toolkit open data to integrate with your own data.
diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
new file mode 100644
index 000000000..b50888d54
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
@@ -0,0 +1,92 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# FINOPSMULTITOOL.PSM1
+# MODULE LOADER
+###########################################################################
+# Purpose: Dot-sources all helpers and analysis modules so they can be
+# imported via Import-Module and used by the TUI.
+#
+# Usage:
+# Import-Module .\FinOpsMultitool.psm1
+# $results = Get-OrphanedResources -Subscriptions $subs -TenantId $tid
+###########################################################################
+
+# -- Ensure required Az modules are loaded ---------------------------------
+# Some terminals have incomplete PSModulePath — add the edition-appropriate user
+# module path. In PowerShell 7 (Core) we must NOT prepend the Windows PowerShell
+# 5.1 module path: it can shadow Core's modules with older, incompatible versions
+# (for example an old Az.Accounts that then blocks a newer Az.Storage from loading).
+$userModDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules'
+if ($userModDir -and (Test-Path $userModDir) -and $env:PSModulePath -notlike "*$userModDir*") {
+ $env:PSModulePath = "$userModDir;$env:PSModulePath"
+}
+if ($PSEdition -eq 'Desktop') {
+ $userModDir5 = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'WindowsPowerShell\Modules'
+ if ($userModDir5 -and (Test-Path $userModDir5) -and $env:PSModulePath -notlike "*$userModDir5*") {
+ $env:PSModulePath = "$userModDir5;$env:PSModulePath"
+ }
+}
+
+foreach ($azMod in @('Az.Accounts', 'Az.Storage', 'Az.ResourceGraph')) {
+ if (-not (Get-Module $azMod)) {
+ Import-Module $azMod -ErrorAction SilentlyContinue
+ }
+}
+# -- Helpers (runspace pool, REST retry, ARG wrapper, MG-scope state) ----
+$helpersPath = Join-Path $PSScriptRoot 'modules\helpers'
+. (Join-Path $helpersPath 'Get-PlainAccessToken.ps1')
+. (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1')
+. (Join-Path $helpersPath 'Search-AzGraphSafe.ps1')
+. (Join-Path $helpersPath 'Confirm-WriteAction.ps1')
+. (Join-Path $helpersPath 'MgCostScope.ps1')
+. (Join-Path $helpersPath 'Read-FinOpsHubData.ps1')
+. (Join-Path $helpersPath 'Invoke-FOHubKustoQuery.ps1')
+. (Join-Path $helpersPath 'Get-FOHubProvider.ps1')
+. (Join-Path $helpersPath 'Get-CostExport.ps1')
+. (Join-Path $helpersPath 'Resolve-CostDataSource.ps1')
+. (Join-Path $helpersPath 'Get-KpiInsights.ps1')
+
+# -- Set script-scope root (some modules reference $script:ScriptRootDir) -
+$script:ScriptRootDir = $PSScriptRoot
+
+# -- Analysis Modules ----------------------------------------------------
+$modulePath = Join-Path $PSScriptRoot 'modules'
+. (Join-Path $modulePath 'Initialize-Scanner.ps1')
+. (Join-Path $modulePath 'Get-TenantHierarchy.ps1')
+. (Join-Path $modulePath 'Get-ContractInfo.ps1')
+. (Join-Path $modulePath 'Get-CostData.ps1')
+. (Join-Path $modulePath 'Get-ResourceCosts.ps1')
+. (Join-Path $modulePath 'Get-TagInventory.ps1')
+. (Join-Path $modulePath 'Get-CostByTag.ps1')
+. (Join-Path $modulePath 'Get-AhbVmSavingsRatio.ps1')
+. (Join-Path $modulePath 'Get-AHBOpportunities.ps1')
+. (Join-Path $modulePath 'Get-ReservationAdvice.ps1')
+. (Join-Path $modulePath 'Get-OptimizationAdvice.ps1')
+. (Join-Path $modulePath 'Get-TagRecommendations.ps1')
+. (Join-Path $modulePath 'Get-CostTrend.ps1')
+. (Join-Path $modulePath 'Get-BillingStructure.ps1')
+. (Join-Path $modulePath 'Get-CommitmentUtilization.ps1')
+. (Join-Path $modulePath 'Get-OrphanedResources.ps1')
+. (Join-Path $modulePath 'Remove-OrphanedResource.ps1')
+. (Join-Path $modulePath 'Enable-HybridBenefit.ps1')
+. (Join-Path $modulePath 'Stop-IdleVm.ps1')
+. (Join-Path $modulePath 'Get-BudgetStatus.ps1')
+. (Join-Path $modulePath 'Get-MaccCommitment.ps1')
+. (Join-Path $modulePath 'Get-AnomalyAlerts.ps1')
+. (Join-Path $modulePath 'Get-SavingsRealized.ps1')
+. (Join-Path $modulePath 'Get-PolicyInventory.ps1')
+. (Join-Path $modulePath 'Get-PolicyRecommendations.ps1')
+. (Join-Path $modulePath 'Get-StorageTierAdvice.ps1')
+. (Join-Path $modulePath 'Get-IdleVMs.ps1')
+. (Join-Path $modulePath 'Get-LegacyResources.ps1')
+. (Join-Path $modulePath 'Get-UnitEconomics.ps1')
+. (Join-Path $modulePath 'Get-VmCostBreakdown.ps1')
+. (Join-Path $modulePath 'Get-SharedCostAllocation.ps1')
+. (Join-Path $modulePath 'Set-CostAllocationRule.ps1')
+. (Join-Path $modulePath 'Get-BillingAccount.ps1')
+. (Join-Path $modulePath 'Get-UsageProportionalAllocation.ps1')
+. (Join-Path $modulePath 'Get-AIWorkloadMetrics.ps1')
+. (Join-Path $modulePath 'Get-CarbonMetrics.ps1')
+. (Join-Path $modulePath 'New-PowerBITemplate.ps1')
diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1
new file mode 100644
index 000000000..5e58f8780
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1
@@ -0,0 +1,2468 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# INVOKE-FINOPSMULTITOOL.PS1
+# INTERACTIVE TERMINAL LAUNCHER FOR FINOPS MULTITOOL
+###########################################################################
+# Purpose: Provides an arrow-key driven TUI for selecting and running
+# FinOps Multitool scan modules without a GUI dependency.
+#
+# Usage: Invoke-FinOpsMultitool
+# Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000'
+# Invoke-FinOpsMultitool -OutputPath './results'
+#
+# Requirements:
+# - PowerShell 5.1+ (Windows) or 7+ (cross-platform)
+# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph
+# - Azure RBAC: Reader + Cost Management Reader on target scope
+###########################################################################
+
+function Invoke-FinOpsMultitool {
+ [CmdletBinding()]
+ param(
+ [string]$SubscriptionId,
+ [string]$OutputPath
+ )
+
+ # -- Load modules (always force-reimport to pick up latest changes) ----
+ $multitoolRoot = $PSScriptRoot
+ $psm1Path = Join-Path $multitoolRoot 'FinOpsMultitool.psm1'
+ if (Test-Path $psm1Path) {
+ Import-Module $psm1Path -Force
+ }
+ else {
+ Write-Error "FinOpsMultitool.psm1 not found at $psm1Path"
+ return
+ }
+
+ # -- Pre-flight: verify required Az modules ----------------------------
+ $requiredModules = @(
+ @{ Name = 'Az.Accounts'; Reason = 'Azure authentication' }
+ @{ Name = 'Az.ResourceGraph'; Reason = 'Resource Graph queries (optimization, governance scans)' }
+ @{ Name = 'Az.Storage'; Reason = 'FinOps Hub data access (reading cost exports)' }
+ )
+ $missing = @()
+ foreach ($req in $requiredModules) {
+ if (-not (Get-Module $req.Name -ErrorAction SilentlyContinue) -and
+ -not (Get-Module $req.Name -ListAvailable -ErrorAction SilentlyContinue)) {
+ $missing += $req
+ }
+ }
+ if ($missing.Count -gt 0) {
+ Write-Host ""
+ Write-Host " MISSING REQUIRED MODULES" -ForegroundColor Red
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ foreach ($m in $missing) {
+ Write-Host " $($m.Name)" -ForegroundColor Red -NoNewline
+ Write-Host " — $($m.Reason)" -ForegroundColor DarkGray
+ }
+ Write-Host ""
+ Write-Host " Install with:" -ForegroundColor White
+ $names = ($missing.Name | ForEach-Object { "'$_'" }) -join ', '
+ Write-Host " Install-Module $names -Scope CurrentUser" -ForegroundColor Yellow
+ Write-Host ""
+ return
+ }
+
+ # -- Scan Module Registry ----------------------------------------------
+ $scanModules = @(
+ # -- Optimization (Resource Graph) --
+ @{ Name = 'Orphaned Resources'; Fn = 'Get-OrphanedResources'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'Idle VMs'; Fn = 'Get-IdleVMs'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'Storage Tier Advice'; Fn = 'Get-StorageTierAdvice'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'Legacy Resources'; Fn = 'Get-LegacyResources'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'AHB Opportunities'; Fn = 'Get-AHBOpportunities'; Selected = $true; Category = 'Optimization' }
+ # -- Governance (run early — other modules depend on these) --
+ @{ Name = 'Tag Inventory'; Fn = 'Get-TagInventory'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Tag Recommendations'; Fn = 'Get-TagRecommendations'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Policy Inventory'; Fn = 'Get-PolicyInventory'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Policy Recommendations'; Fn = 'Get-PolicyRecommendations'; Selected = $true; Category = 'Governance' }
+ # -- Cost Analysis (depends on Tag Inventory for Cost by Tag) --
+ @{ Name = 'Cost Data'; Fn = 'Get-CostData'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Resource Costs'; Fn = 'Get-ResourceCosts'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Cost by Tag'; Fn = 'Get-CostByTag'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Cost Trend'; Fn = 'Get-CostTrend'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Unit Economics'; Fn = 'Get-UnitEconomics'; Selected = $true; Category = 'Cost Analysis' }
+ # -- AI & ML (self-gating — only runs the deep scan when AI is present) --
+ @{ Name = 'AI Workload Metrics'; Fn = 'Get-AIWorkloadMetrics'; Selected = $true; Category = 'AI & ML' }
+ # -- Commitments --
+ @{ Name = 'Reservation Advice'; Fn = 'Get-ReservationAdvice'; Selected = $true; Category = 'Commitments' }
+ @{ Name = 'Commitment Utilization'; Fn = 'Get-CommitmentUtilization'; Selected = $true; Category = 'Commitments' }
+ @{ Name = 'Savings Realized'; Fn = 'Get-SavingsRealized'; Selected = $true; Category = 'Commitments' }
+ # -- Monitoring --
+ @{ Name = 'Budget Status'; Fn = 'Get-BudgetStatus'; Selected = $true; Category = 'Monitoring' }
+ @{ Name = 'Budget History'; Fn = 'Get-BudgetHistory'; Selected = $true; Category = 'Monitoring' }
+ @{ Name = 'Anomaly Alerts'; Fn = 'Get-AnomalyAlerts'; Selected = $true; Category = 'Monitoring' }
+ # -- Advisor --
+ @{ Name = 'Optimization Advice'; Fn = 'Get-OptimizationAdvice'; Selected = $true; Category = 'Advisor' }
+ # -- Sustainability --
+ @{ Name = 'Carbon Emissions'; Fn = 'Get-CarbonMetrics'; Selected = $true; Category = 'Sustainability' }
+ # -- Account --
+ @{ Name = 'Billing Structure'; Fn = 'Get-BillingStructure'; Selected = $false; Category = 'Account' }
+ @{ Name = 'Contract Info'; Fn = 'Get-ContractInfo'; Selected = $true; Category = 'Account' }
+ @{ Name = 'MACC Commitment'; Fn = 'Get-MaccCommitment'; Selected = $true; Category = 'Account' }
+ )
+
+ # -- Permission Requirements per Module --------------------------------
+ # Maps each function to the Azure RBAC role(s) needed and a human-readable reason
+ $permissionInfo = @{
+ 'Get-OrphanedResources' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query resource metadata via Azure Resource Graph.' }
+ 'Get-IdleVMs' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph + Monitor Metrics'; Reason = 'Requires Reader to query VM metadata and Monitor metrics for CPU/network utilization.' }
+ 'Get-StorageTierAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query storage account configurations.' }
+ 'Get-LegacyResources' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires Reader to query VM/disk/network SKUs for legacy and retiring resources.' }
+ 'Get-AHBOpportunities' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query VM license types.' }
+ 'Get-TagInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to inventory resource tags via Resource Graph.' }
+ 'Get-TagRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to analyze existing tags and suggest improvements.' }
+ 'Get-PolicyInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to list policy assignments and definitions.' }
+ 'Get-PolicyRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to evaluate policy coverage gaps.' }
+ 'Get-CostData' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' }
+ 'Get-ResourceCosts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' }
+ 'Get-CostByTag' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to query cost grouped by tag dimensions.' }
+ 'Get-CostTrend' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve historical monthly cost data.' }
+ 'Get-UnitEconomics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics'; Reason = 'Requires amortized cost (Cost Management), capacity counts (Resource Graph), and storage-account used capacity (Monitor UsedCapacity metric) to compute $/vCPU, $/GB RAM and $/GB stored.' }
+ 'Get-AIWorkloadMetrics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API'; Reason = 'Requires Reader to detect AI resources and read Azure OpenAI token metrics, plus Cost Management Reader to map token usage to spend. Skips the deep scan when no AI workloads are present.' }
+ 'Get-ReservationAdvice' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Reservation Recommendations API'; Reason = 'Requires Microsoft.Consumption/reservationRecommendations/read to retrieve reservation purchase advice.' }
+ 'Get-CommitmentUtilization' = @{ Role = 'Cost Management Reader or Reservation Reader'; Scope = 'Reservation Order or Subscription'; API = 'Consumption Reservation Summaries API'; Reason = 'Requires Microsoft.Consumption/reservationSummaries/read. If no reservations exist, this will be empty.' }
+ 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Benefit Utilization API'; Reason = 'Requires Microsoft.CostManagement/benefitUtilizationSummaries/read. Returns empty if no active reservations or savings plans.' }
+ 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' }
+ 'Get-BudgetHistory' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve monthly actuals per budget. Runs only when Budget Status returns budgets.' }
+ 'Get-AnomalyAlerts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Alerts API'; Reason = 'Requires Microsoft.CostManagement/alerts/read. Returns empty if no cost anomalies were detected.' }
+ 'Get-OptimizationAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Advisor API'; Reason = 'Requires Microsoft.Advisor/recommendations/read to retrieve cost optimization recommendations.' }
+ 'Get-CarbonMetrics' = @{ Role = 'Reader or Carbon Optimization Reader'; Scope = 'Subscription'; API = 'Carbon Optimization API'; Reason = 'Requires Microsoft.Carbon read access to query emissions. Emissions publish ~2 months in arrears; returns empty if no published months.' }
+ 'Get-BillingStructure' = @{ Role = 'Billing Reader or EA Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/*/read. This is a billing-scope role, not a subscription role. Contact your billing admin.' }
+ 'Get-ContractInfo' = @{ Role = 'Billing Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/billingProperty/read. May require billing account access beyond subscription Reader.' }
+ 'Get-MaccCommitment' = @{ Role = 'Billing Reader or EA Reader'; Scope = 'Billing Account'; API = 'Consumption Lots API'; Reason = 'Requires a billing role on an EA/MCA billing account to read consumption commitment (MACC) lots. Not applicable to PAYGO/CSP/MSDN.' }
+ }
+
+ # =====================================================================
+ # BANNER
+ # =====================================================================
+ function Show-Banner {
+ Clear-Host
+
+ # Version comes from the toolkit so the TUI and the module cannot drift.
+ # Get-VersionNumber is a sibling private function, absent when this script runs standalone.
+ if (-not (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue)) {
+ $verFile = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'Get-VersionNumber.ps1'
+ if (Test-Path -Path $verFile) { . $verFile }
+ }
+ $verText = if (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue) { "v$(Get-VersionNumber)" } else { '' }
+ # Keeps the banner box interior at a fixed 72 characters for any version length.
+ $verPad = ' ' * [math]::Max(1, 32 - $verText.Length)
+
+ $banner = @"
+
+ ╔════════════════════════════════════════════════════════════════════════╗
+ ║ ║
+ ║ ███████╗██╗███╗ ██╗ ██████╗ ██████╗ ███████╗ ║
+ ║ ██╔════╝██║████╗ ██║██╔═══██╗██╔══██╗██╔════╝ ║
+ ║ █████╗ ██║██╔██╗ ██║██║ ██║██████╔╝███████╗ ║
+ ║ ██╔══╝ ██║██║╚██╗██║██║ ██║██╔═══╝ ╚════██║ ║
+ ║ ██║ ██║██║ ╚████║╚██████╔╝██║ ███████║ ║
+ ║ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝ ║
+ ║ ║
+ ║ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗████████╗ ██████╗ ██████╗ ██╗ ║
+ ║ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║╚══██╔══╝██╔═══██╗██╔═══██╗██║ ║
+ ║ ██╔████╔██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
+ ║ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
+ ║ ║
+ ║ Azure FinOps Scanner & Optimizer$verPad$verText ║
+ ║ ║
+ ╚════════════════════════════════════════════════════════════════════════╝
+
+"@
+ Write-Host $banner -ForegroundColor Cyan
+ }
+
+ # =====================================================================
+ # CONSOLE HELPERS
+ # =====================================================================
+ # Menu rows must never reach the console width. A row that wraps occupies two
+ # physical lines, which desynchronizes the cursor-up math the pickers use to
+ # redraw in place and makes the menu smear down the screen.
+ function Get-MenuWidth {
+ param([int]$Cap)
+ $consoleWidth = 0
+ try { $consoleWidth = [Console]::WindowWidth } catch { $consoleWidth = 0 }
+ if ($consoleWidth -lt 20) { return $Cap }
+ return [math]::Min($Cap, $consoleWidth - 1)
+ }
+
+ # =====================================================================
+ # DATA SOURCE PICKER
+ # =====================================================================
+ function Select-DataSource {
+ param(
+ [string]$TenantId,
+ [array]$Subscriptions
+ )
+
+ Write-Host ""
+ Write-Host " DATA SOURCE" -ForegroundColor Cyan
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ Write-Host ""
+
+ # Try to detect a FinOps Hub in the selected subscriptions
+ $hubStorage = $null
+ Write-Host " Checking for FinOps Hub deployment..." -ForegroundColor DarkGray
+ foreach ($sub in $Subscriptions) {
+ try {
+ $query = "resources | where type == 'microsoft.storage/storageaccounts' and tags['cm-resource-parent'] contains 'Microsoft.Cloud/hubs' | project name, resourceGroup, subscriptionId, location"
+ $result = Search-AzGraph -Query $query -Subscription $sub.Id -ErrorAction SilentlyContinue
+ if ($result -and @($result).Count -gt 0) {
+ $hubStorage = $result[0]
+ break
+ }
+ }
+ catch { }
+ }
+
+ if ($hubStorage) {
+ Write-Host " FinOps Hub detected: " -ForegroundColor Green -NoNewline
+ Write-Host "$($hubStorage.name)" -ForegroundColor White -NoNewline
+ Write-Host " ($($hubStorage.resourceGroup))" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [1] FinOps Hub" -ForegroundColor Green -NoNewline
+ Write-Host " - Pre-processed data from your Hub's ingestion pipeline" -ForegroundColor DarkGray
+ Write-Host " Faster, consistent, includes normalized/amortized costs" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [2] Cost Management API" -ForegroundColor Yellow -NoNewline
+ Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray
+ Write-Host " Real-time, no Hub required, subject to API throttling" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [3] Resource Graph only" -ForegroundColor DarkGray -NoNewline
+ Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray
+ Write-Host ""
+
+ while ($true) {
+ Write-Host " Select [1/2/3]: " -ForegroundColor White -NoNewline
+ $choice = Read-Host
+ switch ($choice.Trim()) {
+ '1' {
+ # Large-hub heads-up: the [1] Hub choice uses the scalable
+ # Kusto engine when the hub has an ADX/Fabric cluster (auto-
+ # discovered) or FINOPS_HUB_KUSTO_URI is set (ftklocal). If
+ # neither exists, cost scans fall back to the STORAGE READER,
+ # which loads cost rows into PowerShell - slow / memory-heavy
+ # on large hubs. Warn and let the user switch to the live API.
+ $hubSubIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $prov = $null
+ try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { }
+ if ($prov -and $prov.Found) {
+ # A scalable Kusto path exists - no warning needed.
+ return @{ Source = 'Hub'; HubStorage = $hubStorage }
+ }
+ Write-Host ""
+ Write-Host " Note: this FinOps Hub has no Azure Data Explorer (Kusto) cluster." -ForegroundColor Yellow
+ Write-Host " Cost scans will use the storage reader, which loads cost rows into" -ForegroundColor DarkGray
+ Write-Host " memory. On a large hub (tens of GB) this can be slow or run out of" -ForegroundColor DarkGray
+ Write-Host " memory before completing." -ForegroundColor DarkGray
+ Write-Host " For the scalable engine path: deploy ADX/Fabric on the hub, or set" -ForegroundColor DarkGray
+ Write-Host " FINOPS_HUB_KUSTO_URI to a local ftklocal emulator, then re-run." -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " Switch to the live Cost Management API instead? " -ForegroundColor White -NoNewline
+ Write-Host "(N = continue with the storage reader)" -ForegroundColor DarkGray
+ $useApi = Read-Host " Select [Y/N]"
+ if ($useApi.Trim() -match '^(y|yes)$') {
+ return @{ Source = 'API'; HubStorage = $hubStorage }
+ }
+ return @{ Source = 'Hub'; HubStorage = $hubStorage }
+ }
+ '2' { return @{ Source = 'API'; HubStorage = $hubStorage } }
+ '3' { return @{ Source = 'GraphOnly'; HubStorage = $hubStorage } }
+ default { Write-Host " Invalid choice." -ForegroundColor Red }
+ }
+ }
+ }
+ else {
+ Write-Host " No FinOps Hub found in selected subscriptions." -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [1] Cost Management API" -ForegroundColor Yellow -NoNewline
+ Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray
+ Write-Host " Real-time, subject to API throttling on large tenants" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [2] Resource Graph only" -ForegroundColor DarkGray -NoNewline
+ Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray
+ Write-Host ""
+
+ while ($true) {
+ Write-Host " Select [1/2]: " -ForegroundColor White -NoNewline
+ $choice = Read-Host
+ switch ($choice.Trim()) {
+ '1' { return @{ Source = 'API'; HubStorage = $null } }
+ '2' { return @{ Source = 'GraphOnly'; HubStorage = $null } }
+ default { Write-Host " Invalid choice." -ForegroundColor Red }
+ }
+ }
+ }
+ }
+
+ # =====================================================================
+ # SUBSCRIPTION PICKER
+ # =====================================================================
+ function Select-Subscription {
+ param([string]$PreselectedId)
+
+ Write-Host " Checking Azure connection..." -ForegroundColor DarkGray
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+ if (-not $ctx) {
+ Write-Host " Not connected. Launching browser login..." -ForegroundColor Yellow
+ Connect-AzAccount | Out-Null
+ $ctx = Get-AzContext
+ }
+ Write-Host " Signed in as: $($ctx.Account.Id)" -ForegroundColor Green
+ Write-Host ""
+
+ # -- Tenant picker ------------------------------------------------
+ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ if ($tenants.Count -gt 1) {
+ Write-Host " $($tenants.Count) tenants available:" -ForegroundColor White
+ Write-Host ""
+
+ $tCursor = 0
+ $currentTenantId = $ctx.Tenant.Id
+ # Pre-select current tenant
+ for ($t = 0; $t -lt $tenants.Count; $t++) {
+ if ($tenants[$t].TenantId -eq $currentTenantId) { $tCursor = $t; break }
+ }
+
+ while ($true) {
+ $tWidth = Get-MenuWidth 85
+ [Console]::SetCursorPosition(0, [Console]::CursorTop)
+ for ($t = 0; $t -lt $tenants.Count; $t++) {
+ $tPrefix = if ($t -eq $tCursor) { ' > ' } else { ' ' }
+ $tColor = if ($t -eq $tCursor) { 'Green' } else { 'Gray' }
+ $tLabel = if ($tenants[$t].Name -and $tenants[$t].Name -ne $tenants[$t].TenantId) {
+ "$($tenants[$t].Name) ($($tenants[$t].TenantId))"
+ }
+ else { $tenants[$t].TenantId }
+ $current = if ($tenants[$t].TenantId -eq $currentTenantId) { ' (current)' } else { '' }
+ $tLine = "$tPrefix$tLabel$current"
+ if ($tLine.Length -gt $tWidth) { $tLine = $tLine.Substring(0, $tWidth - 3) + '...' }
+ Write-Host $tLine.PadRight($tWidth) -ForegroundColor $tColor
+ }
+ Write-Host ""
+ Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray
+
+ $tKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($tKey.VirtualKeyCode) {
+ 38 { if ($tCursor -gt 0) { $tCursor-- } }
+ 40 { if ($tCursor -lt $tenants.Count - 1) { $tCursor++ } }
+ 13 {
+ $selectedTenant = $tenants[$tCursor]
+ if ($selectedTenant.TenantId -ne $currentTenantId) {
+ Write-Host ""
+ Write-Host " Switching to tenant: $($selectedTenant.Name)..." -ForegroundColor Yellow
+ Connect-AzAccount -TenantId $selectedTenant.TenantId | Out-Null
+ $ctx = Get-AzContext
+ Write-Host " Connected to: $($ctx.Tenant.Id)" -ForegroundColor Green
+ }
+ else {
+ Write-Host ""
+ Write-Host " Staying in current tenant." -ForegroundColor Green
+ }
+ break
+ }
+ 81 {
+ Write-Host ""
+ Write-Host " Staying in current tenant." -ForegroundColor Green
+ break
+ }
+ }
+ if ($tKey.VirtualKeyCode -eq 13 -or $tKey.VirtualKeyCode -eq 81) { break }
+
+ # Move cursor back up to re-render
+ $tLinesToClear = $tenants.Count + 2
+ [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $tLinesToClear))
+ }
+ Write-Host ""
+ }
+ elseif ($tenants.Count -eq 1) {
+ $tLabel = if ($tenants[0].Name -and $tenants[0].Name -ne $tenants[0].TenantId) { $tenants[0].Name } else { $tenants[0].TenantId }
+ Write-Host " Tenant: $tLabel" -ForegroundColor Green
+ Write-Host ""
+ }
+
+ if ($PreselectedId) {
+ $sub = Get-AzSubscription -SubscriptionId $PreselectedId -ErrorAction SilentlyContinue
+ if ($sub) {
+ Write-Host " Using subscription: $($sub.Name)" -ForegroundColor Green
+ return @($sub)
+ }
+ Write-Host " Subscription $PreselectedId not found, showing picker..." -ForegroundColor Yellow
+ }
+
+ # Scope subscription enumeration to the SELECTED tenant only.
+ # Get-AzSubscription with no -TenantId returns subscriptions across every
+ # tenant the signed-in account can access, which incorrectly mixes tenants
+ # together when the user picks one tenant and chooses "All subscriptions".
+ $effectiveTenantId = (Get-AzContext -ErrorAction SilentlyContinue).Tenant.Id
+ $allSubs = @(Get-AzSubscription -TenantId $effectiveTenantId -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ if ($allSubs.Count -eq 0) {
+ Write-Error "No enabled subscriptions found in tenant $effectiveTenantId."
+ return $null
+ }
+ if ($allSubs.Count -eq 1) {
+ Write-Host " Using only subscription: $($allSubs[0].Name)" -ForegroundColor Green
+ return $allSubs
+ }
+
+ # Multi-sub picker
+ Write-Host " Found $($allSubs.Count) subscriptions. Select scope:" -ForegroundColor White
+ Write-Host ""
+ Write-Host " [A] All subscriptions" -ForegroundColor White
+ Write-Host " [S] Single subscription (pick from list)" -ForegroundColor White
+ Write-Host ""
+ $choice = Read-Host " Choice (A/S)"
+
+ if ($choice -eq 'A' -or $choice -eq 'a') {
+ Write-Host " Scanning all $($allSubs.Count) subscriptions" -ForegroundColor Green
+ return $allSubs
+ }
+
+ # Arrow-key single subscription picker
+ $cursor = 0
+ $pageSize = 15
+ $offset = 0
+
+ while ($true) {
+ # Render list
+ $renderStart = $offset
+ $renderEnd = [math]::Min($offset + $pageSize, $allSubs.Count) - 1
+ $width = Get-MenuWidth 75
+ [Console]::SetCursorPosition(0, [Console]::CursorTop)
+
+ for ($i = $renderStart; $i -le $renderEnd; $i++) {
+ $prefix = if ($i -eq $cursor) { ' > ' } else { ' ' }
+ $color = if ($i -eq $cursor) { 'Green' } else { 'Gray' }
+ $line = "$prefix$($allSubs[$i].Name)"
+ if ($line.Length -gt $width) { $line = $line.Substring(0, $width - 3) + '...' }
+ Write-Host $line.PadRight($width) -ForegroundColor $color
+ }
+ Write-Host ""
+ Write-Host " ↑↓ Navigate │ Enter = Select │ Q = Cancel" -ForegroundColor DarkGray
+
+ $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($key.VirtualKeyCode) {
+ 38 {
+ # Up
+ if ($cursor -gt 0) { $cursor-- }
+ if ($cursor -lt $offset) { $offset = $cursor }
+ }
+ 40 {
+ # Down
+ if ($cursor -lt $allSubs.Count - 1) { $cursor++ }
+ if ($cursor -ge $offset + $pageSize) { $offset = $cursor - $pageSize + 1 }
+ }
+ 13 {
+ # Enter
+ Write-Host ""
+ Write-Host " Selected: $($allSubs[$cursor].Name)" -ForegroundColor Green
+ return @($allSubs[$cursor])
+ }
+ 81 { return $null } # Q
+ }
+
+ # Move cursor back up to re-render
+ $linesToClear = ($renderEnd - $renderStart + 1) + 2
+ [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $linesToClear))
+ }
+ }
+
+ # =====================================================================
+ # SCAN MODULE PICKER (checkbox menu)
+ # =====================================================================
+ function Select-ScanModules {
+ param([array]$Modules)
+
+ $cursor = 0
+ $categories = $Modules | ForEach-Object { $_.Category } | Select-Object -Unique
+
+ while ($true) {
+ # Build display lines grouped by category
+ $lines = @()
+ $lineToIndex = @{} # map display line -> module index
+ $moduleIdx = 0
+
+ foreach ($cat in $categories) {
+ $lines += " ── $cat ──"
+ $lineToIndex[$lines.Count - 1] = -1 # category header, not selectable
+
+ $catModules = $Modules | Where-Object { $_.Category -eq $cat }
+ foreach ($mod in $catModules) {
+ $idx = [array]::IndexOf($Modules, $mod)
+ $check = if ($mod.Selected) { '[x]' } else { '[ ]' }
+ $lines += " $check $($mod.Name)"
+ $lineToIndex[$lines.Count - 1] = $idx
+ }
+ $lines += ''
+ $lineToIndex[$lines.Count - 1] = -1
+ }
+
+ # Find selectable line indices
+ $selectableLines = @()
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lineToIndex[$i] -ge 0) { $selectableLines += $i }
+ }
+
+ if ($cursor -ge $selectableLines.Count) { $cursor = $selectableLines.Count - 1 }
+ $activeLine = $selectableLines[$cursor]
+
+ # Render
+ Clear-Host
+ Write-Host ""
+ Write-Host " SELECT SCANS" -ForegroundColor White
+ Write-Host " ↑↓ Move │ Space = Toggle │ A = All │ N = None │ Enter = Run │ Q = Quit" -ForegroundColor DarkGray
+ Write-Host ""
+
+ $selectedCount = ($Modules | Where-Object { $_.Selected }).Count
+
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lineToIndex[$i] -eq -1) {
+ # Category header or blank
+ if ($lines[$i] -match '──') {
+ Write-Host $lines[$i] -ForegroundColor Yellow
+ }
+ else {
+ Write-Host $lines[$i]
+ }
+ }
+ else {
+ $isActive = ($i -eq $activeLine)
+ $mod = $Modules[$lineToIndex[$i]]
+ $check = if ($mod.Selected) { '[x]' } else { '[ ]' }
+ $pointer = if ($isActive) { ' >' } else { ' ' }
+ $color = if ($isActive -and $mod.Selected) { 'Green' }
+ elseif ($isActive) { 'White' }
+ elseif ($mod.Selected) { 'DarkGreen' }
+ else { 'Gray' }
+ Write-Host " $pointer $check $($mod.Name)" -ForegroundColor $color
+ }
+ }
+
+ Write-Host ""
+ Write-Host " $selectedCount of $($Modules.Count) scans selected" -ForegroundColor DarkGray
+ Write-Host ""
+
+ # Read key
+ $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($key.VirtualKeyCode) {
+ 38 { if ($cursor -gt 0) { $cursor-- } } # Up
+ 40 { if ($cursor -lt $selectableLines.Count - 1) { $cursor++ } } # Down
+ 32 {
+ # Space = toggle
+ $modIdx = $lineToIndex[$selectableLines[$cursor]]
+ $Modules[$modIdx].Selected = -not $Modules[$modIdx].Selected
+ }
+ 65 {
+ # A = select all
+ foreach ($m in $Modules) { $m.Selected = $true }
+ }
+ 78 {
+ # N = select none
+ foreach ($m in $Modules) { $m.Selected = $false }
+ }
+ 13 {
+ # Enter = run
+ $selected = $Modules | Where-Object { $_.Selected }
+ if ($selected.Count -eq 0) {
+ Write-Host " No scans selected. Press any key..." -ForegroundColor Red
+ $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ }
+ else { return $Modules }
+ }
+ 81 { return $null } # Q = quit
+ }
+ }
+ }
+
+ # =====================================================================
+ # RUN SELECTED SCANS
+ # =====================================================================
+ function Invoke-SelectedScans {
+ param(
+ [array]$Modules,
+ [array]$Subscriptions,
+ [string]$TenantId,
+ [hashtable]$DataSource
+ )
+
+ $selected = $Modules | Where-Object { $_.Selected }
+ $results = @{}
+ $total = $selected.Count
+ $current = 0
+
+ # Pre-load Hub data if Hub source selected
+ $hubCostData = $null
+ $hubResourceCosts = $null
+ $hubRaw = $null
+ $hubTagInventory = $null
+ $hubCostByTag = $null
+
+ # Scalable Kusto path: when a FinOps Hub Kusto database is reachable
+ # (a FINOPS_HUB_KUSTO_URI override for an ftklocal emulator or a pinned
+ # cluster, or a discovered ADX/Fabric cluster), push aggregation into
+ # the engine and return only summaries - never load raw rows. This is
+ # what lets the tool scale to large hub datasets. Falls back to the
+ # storage reader below when no cluster is available.
+ $kustoProvider = $null
+ if ($DataSource.Source -eq 'Hub' -or $env:FINOPS_HUB_KUSTO_URI) {
+ $subIdsForDisco = @($Subscriptions | ForEach-Object { $_.Id })
+ $kp = Resolve-FOHubProvider -Subscriptions $subIdsForDisco
+ if ($kp -and $kp.Found) { $kustoProvider = $kp }
+ }
+
+ if ($kustoProvider) {
+ Write-Host ""
+ Write-Host " Querying FinOps Hub Kusto database ($($kustoProvider.Mode))..." -ForegroundColor Green
+ $cs = Get-FOHubCostSummary -Provider $kustoProvider
+ if (-not ($cs -is [System.Collections.IDictionary] -and $cs.Contains('Error') -and $cs.Error)) { $hubCostData = $cs }
+ $rc = Get-FOHubResourceCosts -Provider $kustoProvider
+ if (-not ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error)) { $hubResourceCosts = $rc }
+ $ct = Get-FOHubCostByTag -Provider $kustoProvider
+ if (-not ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error)) { $hubCostByTag = $ct }
+ Write-Host " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray
+ }
+ elseif ($DataSource.HubStorage) {
+ # Storage reader: small-dataset convenience path (rows loaded into
+ # PowerShell). For large hubs, the Kusto path above is preferred.
+ $hub = $DataSource.HubStorage
+ if ($DataSource.Source -eq 'Hub') {
+ Write-Host ""
+ Write-Host " Loading cost data from FinOps Hub storage (small-dataset reader)..." -ForegroundColor Green
+ Write-Host " For large hubs, query the Kusto database instead (ADX/Fabric, or set FINOPS_HUB_KUSTO_URI for ftklocal)." -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " Loading Hub tag data for fast tag scans..." -ForegroundColor DarkGray
+ }
+ try {
+ $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1
+ }
+ catch {
+ Write-Host " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow
+ $hubRaw = $null
+ }
+ if ($hubRaw -and @($hubRaw).Count -gt 0) {
+ $hubTagInventory = ConvertTo-TagInventoryFromHub -HubData $hubRaw
+
+ # Hub tag coverage only reflects resources with cost data — query ARG for true counts
+ try {
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $totalBody = @{
+ subscriptions = @($subIds)
+ query = "resources | summarize TotalCount = count()"
+ options = @{ resultFormat = 'objectArray' }
+ } | ConvertTo-Json -Depth 5
+ $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody
+ if ($totalResp.StatusCode -eq 200) {
+ $totalRows = @(($totalResp.Content | ConvertFrom-Json).data)
+ if ($totalRows.Count -gt 0) {
+ $argTotal = [int]$totalRows[0].TotalCount
+
+ $untaggedBody = @{
+ subscriptions = @($subIds)
+ query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()"
+ options = @{ resultFormat = 'objectArray' }
+ } | ConvertTo-Json -Depth 5
+ $untaggedResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $untaggedBody
+ if ($untaggedResp.StatusCode -eq 200) {
+ $untaggedRows = @(($untaggedResp.Content | ConvertFrom-Json).data)
+ $argUntagged = if ($untaggedRows.Count -gt 0) { [int]$untaggedRows[0].UntaggedCount } else { 0 }
+
+ $argTagged = [math]::Max(0, $argTotal - $argUntagged)
+ $argCoverage = if ($argTotal -gt 0) { [math]::Round(($argTagged / $argTotal) * 100, 1) } else { 0 }
+
+ # Override Hub coverage with ARG-based coverage
+ $hubTagInventory = $hubTagInventory | ForEach-Object {
+ $_.TotalResources = $argTotal
+ $_.TaggedCount = $argTagged
+ $_.UntaggedCount = $argUntagged
+ $_.TagCoverage = $argCoverage
+ $_
+ }
+ Write-Host " Tag coverage corrected via Resource Graph: $argCoverage% ($argTagged/$argTotal)" -ForegroundColor DarkGray
+ }
+ }
+ }
+ }
+ catch {
+ Write-Host " Could not verify tag coverage via ARG: $($_.Exception.Message)" -ForegroundColor DarkGray
+ }
+
+ if ($DataSource.Source -eq 'Hub') {
+ $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw
+ $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw
+
+ # Hub exports are historical actuals — enrich with live forecast from Cost Management API
+ try {
+ Write-Host " Fetching forecast data from Cost Management API..." -ForegroundColor DarkGray
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $forecastFilled = $false
+
+ # Try MG-scope forecast first
+ $fctTenantId = (Get-AzContext).Tenant.Id
+ if ($fctTenantId) {
+ $fctBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'SubscriptionId' })
+ }
+ includeActualCost = $false
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fctPath = "/providers/Microsoft.Management/managementGroups/$fctTenantId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01"
+ $fctResp = Invoke-AzRestMethodWithRetry -Path $fctPath -Method POST -Payload $fctBody
+ if ($fctResp.StatusCode -eq 200) {
+ $fctResult = ($fctResp.Content | ConvertFrom-Json)
+ if ($fctResult.properties.rows -and $fctResult.properties.rows.Count -gt 0) {
+ $fctSums = @{}
+ foreach ($row in $fctResult.properties.rows) {
+ $subId = $row[1]
+ if (-not $fctSums.ContainsKey($subId)) { $fctSums[$subId] = 0 }
+ $fctSums[$subId] += [double]$row[0]
+ }
+ foreach ($subId in $fctSums.Keys) {
+ if ($hubCostData.ContainsKey($subId)) {
+ # Full-month projection = actual MTD + remaining forecast
+ $actual = $hubCostData[$subId].Actual
+ $hubCostData[$subId].Forecast = [math]::Round($actual + $fctSums[$subId], 2)
+ }
+ }
+ $forecastFilled = $true
+ Write-Host " Forecast data loaded for $($fctSums.Count) subscription(s)" -ForegroundColor Green
+ }
+ }
+ }
+
+ # Per-subscription fallback if MG-scope failed
+ if (-not $forecastFilled -and $Subscriptions) {
+ $fctHits = 0
+ foreach ($sub in $Subscriptions) {
+ try {
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ }
+ includeActualCost = $false
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+ $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $fctTotal = 0
+ foreach ($row in $fRes.properties.rows) { $fctTotal += [double]$row[0] }
+ if ($hubCostData.ContainsKey($sub.Id)) {
+ # Full-month projection = actual MTD + remaining forecast
+ $actual = $hubCostData[$sub.Id].Actual
+ $hubCostData[$sub.Id].Forecast = [math]::Round($actual + $fctTotal, 2)
+ $fctHits++
+ }
+ }
+ }
+ }
+ catch { }
+ }
+ if ($fctHits -gt 0) { Write-Host " Forecast data loaded for $fctHits subscription(s)" -ForegroundColor Green }
+ }
+ }
+ catch {
+ Write-Host " Forecast data unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray
+ }
+
+ Write-Host " Hub data loaded: $(@($hubRaw).Count) cost records, $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor Green
+ }
+ else {
+ Write-Host " Hub tag data ready: $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor DarkGray
+ }
+ }
+ else {
+ $hubRaw = $null
+ if ($DataSource.Source -eq 'Hub') {
+ Write-Host " No Hub data found — falling back to Cost Management API" -ForegroundColor Yellow
+ $DataSource.Source = 'API'
+ }
+ }
+ if ($DataSource.Source -eq 'Hub') { Write-Host "" }
+ }
+
+ $srcLabel = switch ($DataSource.Source) {
+ 'Hub' { "FinOps Hub ($($DataSource.HubStorage.name))" }
+ 'API' { "Cost Management API (real-time)" }
+ 'GraphOnly' { "Resource Graph only" }
+ }
+ Write-SectionHeader "RUNNING $total SCANS"
+ $srcColor = switch ($DataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } }
+ Write-Host " $srcLabel" -ForegroundColor $srcColor
+ Write-Host ""
+
+ foreach ($mod in $selected) {
+ $current++
+ $pct = [math]::Round(($current / $total) * 100)
+ $bar = ('█' * [math]::Floor($pct / 5)).PadRight(20, '░')
+
+ Write-Host " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White -NoNewline
+
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
+ try {
+ $fn = $mod.Fn
+ $output = $null
+
+ # Route parameters based on what each function expects
+ # Hub shortcut: return pre-loaded Hub data for cost/tag modules
+ switch ($fn) {
+ { $_ -eq 'Get-CostData' -and $hubCostData } {
+ $output = $hubCostData; break
+ }
+ { $_ -eq 'Get-ResourceCosts' -and $hubResourceCosts } {
+ $output = $hubResourceCosts; break
+ }
+ { $_ -eq 'Get-TagInventory' -and $hubTagInventory } {
+ $output = $hubTagInventory; break
+ }
+ { $_ -eq 'Get-CostByTag' -and $hubCostByTag } {
+ # Scalable Kusto path: cost-by-tag summarized in-engine.
+ $output = $hubCostByTag; break
+ }
+ { $_ -eq 'Get-CostByTag' -and $hubRaw } {
+ # Build cost-by-tag from Hub data — zero API calls
+ $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ elseif ($hubTagInventory) { $hubTagInventory.TagNames }
+ else { @{} }
+ $output = ConvertTo-CostByTagFromHub -HubData $hubRaw -ExistingTags $existingTags; break
+ }
+ 'Get-TagRecommendations' {
+ $tags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ elseif ($hubTagInventory) { $hubTagInventory.TagNames }
+ else { @{} }
+ $output = & $fn -ExistingTags $tags; break
+ }
+ 'Get-PolicyRecommendations' {
+ $assignments = if ($results.ContainsKey('Get-PolicyInventory') -and $results['Get-PolicyInventory'].Assignments) {
+ $results['Get-PolicyInventory'].Assignments
+ }
+ else { @() }
+ $output = & $fn -ExistingAssignments $assignments; break
+ }
+ 'Get-BudgetStatus' {
+ $costData = if ($results.ContainsKey('Get-CostData') -and $results['Get-CostData'] -is [hashtable]) {
+ $results['Get-CostData']
+ }
+ elseif ($hubCostData -is [hashtable]) { $hubCostData }
+ else { @{} }
+ $output = & $fn -Subscriptions $Subscriptions -CostData $costData; break
+ }
+ 'Get-BudgetHistory' {
+ # Depends on Budget Status — reuse the budgets it already found
+ $budgetResult = if ($results.ContainsKey('Get-BudgetStatus')) { $results['Get-BudgetStatus'] } else { $null }
+ $budgetRows = if ($budgetResult -and $budgetResult.Budgets) { @($budgetResult.Budgets) } else { @() }
+ if ($budgetRows.Count -gt 0) {
+ # Reuse Cost Trend's already-fetched monthly spend so we don't
+ # re-hit the throttle-prone Cost Management Query API.
+ $trendResult = if ($results.ContainsKey('Get-CostTrend')) { $results['Get-CostTrend'] } else { $null }
+ $output = & $fn -Budgets $budgetRows -MonthsBack 6 -CostTrend $trendResult
+ }
+ else {
+ $output = @()
+ }
+ break
+ }
+ 'Get-MaccCommitment' {
+ # Pass the detected agreement type from Contract Info when available
+ $agreementType = ''
+ if ($results.ContainsKey('Get-ContractInfo') -and $results['Get-ContractInfo']) {
+ $agreementType = @($results['Get-ContractInfo'])[0].AgreementType
+ }
+ $output = & $fn -Subscriptions $Subscriptions -AgreementType $agreementType; break
+ }
+ { $_ -eq 'Get-CostByTag' -and -not $hubRaw } {
+ # No Hub data — fall back to API
+ $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ else { @{} }
+ $output = & $fn -TenantId $TenantId -ExistingTags $existingTags -Subscriptions $Subscriptions; break
+ }
+ 'Get-AIWorkloadMetrics' {
+ # AI scan runs its own cheap ARG footprint gate; when the
+ # Hub source is selected, hand it the pre-loaded export so
+ # spend + token volume come from the export, not the
+ # Monitor + Cost Management APIs.
+ $aiParams = @{ TenantId = $TenantId; Subscriptions = $Subscriptions }
+ if ($DataSource.Source -eq 'Hub' -and $hubRaw -and @($hubRaw).Count -gt 0) {
+ $aiParams['HubData'] = $hubRaw
+ }
+ $output = & $fn @aiParams; break
+ }
+ default {
+ # Build params — include TenantId if the function accepts it
+ $params = @{ Subscriptions = $Subscriptions }
+ $cmdInfo = Get-Command $fn -ErrorAction SilentlyContinue
+ if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('TenantId') -and $TenantId) {
+ $params['TenantId'] = $TenantId
+ }
+ $output = & $fn @params
+ }
+ }
+
+ $sw.Stop()
+ $count = if ($output) { @($output).Count } else { 0 }
+ $results[$fn] = $output
+
+ Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Green -NoNewline
+ Write-Host " $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor DarkGray
+ }
+ catch {
+ $sw.Stop()
+ Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Red -NoNewline
+ Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
+ $results[$mod.Fn] = @()
+ $results["_error_$($mod.Fn)"] = $_.Exception.Message
+ }
+ }
+
+ return $results
+ }
+
+ # =====================================================================
+ # RESULTS SUMMARY
+ # =====================================================================
+ function Write-SectionHeader {
+ param([string]$Title, [string]$Color = 'Cyan')
+ $line = '═' * 55
+ Write-Host ""
+ Write-Host " $line" -ForegroundColor $Color
+ Write-Host " $Title" -ForegroundColor $Color
+ Write-Host " $line" -ForegroundColor $Color
+ }
+
+ # Write a line with dollar amounts ($1,234) highlighted in green
+ function Write-ColorizedLine {
+ param(
+ [string]$Text,
+ [string]$DefaultColor = 'White',
+ [string]$MoneyColor = 'Green'
+ )
+ # Split on dollar-amount patterns, render them in green
+ $parts = [regex]::Split($Text, '(\$[\d,]+\.?\d*(?:/\w+)?)')
+ foreach ($part in $parts) {
+ if ($part -match '^\$[\d,]+\.?\d*') {
+ Write-Host $part -ForegroundColor $MoneyColor -NoNewline
+ }
+ else {
+ Write-Host $part -ForegroundColor $DefaultColor -NoNewline
+ }
+ }
+ Write-Host ""
+ }
+ function Show-PermissionReadout {
+ param(
+ [string]$Fn,
+ [hashtable]$PermissionInfo,
+ [string]$Activity
+ )
+ $pInfo = if ($PermissionInfo -and $PermissionInfo.ContainsKey($Fn)) { $PermissionInfo[$Fn] } else { $null }
+ $what = if ($Activity) { " reading $Activity" } else { '' }
+ Write-Host " [!] ACCESS DENIED$what (the API returned access denied, not empty results)." -ForegroundColor Red
+ if ($pInfo) {
+ Write-Host " Required role: $($pInfo.Role)" -ForegroundColor Yellow
+ Write-Host " Scope: $($pInfo.Scope)" -ForegroundColor Yellow
+ Write-Host " API: $($pInfo.API)" -ForegroundColor DarkGray
+ Write-Host " $($pInfo.Reason)" -ForegroundColor DarkGray
+ Write-Host " Ask a billing or subscription admin to assign the matching role, then re-scan." -ForegroundColor DarkGray
+ }
+ }
+
+ function Show-ResultsSummary {
+ param(
+ [hashtable]$Results,
+ [array]$Modules,
+ [string]$ExportPath,
+ [array]$Subscriptions
+ )
+
+ # Build sub ID → name lookup for display functions
+ $subNameLookup = @{}
+ if ($Subscriptions) { foreach ($s in $Subscriptions) { if ($s.Id -and $s.Name) { $subNameLookup[$s.Id] = $s.Name } } }
+
+ Write-SectionHeader 'SCAN COMPLETE'
+ Write-Host ""
+
+ $totalFindings = 0
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ $errorKey = "_error_$($mod.Fn)"
+ $hasError = $Results.ContainsKey($errorKey)
+ $count = if ($data) { @($data).Count } else { 0 }
+ $totalFindings += $count
+ if ($hasError) {
+ $icon = '!'
+ $color = 'Red'
+ $suffix = 'error (see details below)'
+ }
+ elseif ($count -gt 0) {
+ $icon = '*'
+ $color = 'Yellow'
+ $suffix = "$count findings"
+ }
+ else {
+ $icon = '-'
+ $color = 'DarkGray'
+ $suffix = '0 findings'
+ }
+ Write-Host " $icon $($mod.Name.PadRight(30)) $suffix" -ForegroundColor $color
+ }
+
+ Write-Host ""
+ Write-Host " Total findings: $totalFindings" -ForegroundColor White
+ Write-Host ""
+
+ # -- Display results per module ------------------------------------
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ if (-not $data -or @($data).Count -eq 0) {
+ # Show why data is missing — error or permissions
+ $errorKey = "_error_$($mod.Fn)"
+ $errorMsg = if ($Results.ContainsKey($errorKey)) { $Results[$errorKey] } else { $null }
+ $pInfo = if ($permissionInfo.ContainsKey($mod.Fn)) { $permissionInfo[$mod.Fn] } else { $null }
+
+ Write-SectionHeader $mod.Name
+ if ($errorMsg) {
+ # Detect permission-related errors
+ $isPermError = $errorMsg -match '(?i)403|401|Forbidden|Unauthorized|AuthorizationFailed|does not have authorization|InsufficientPermissions|BillingAccountNotFound'
+ if ($isPermError -and $pInfo) {
+ Write-Host " [!] ACCESS DENIED" -ForegroundColor Red
+ Write-Host " $errorMsg" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " Required role: $($pInfo.Role)" -ForegroundColor Yellow
+ Write-Host " Scope: $($pInfo.Scope)" -ForegroundColor Yellow
+ Write-Host " API: $($pInfo.API)" -ForegroundColor DarkGray
+ Write-Host " $($pInfo.Reason)" -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " [!] ERROR: $errorMsg" -ForegroundColor Red
+ if ($pInfo) {
+ Write-Host " If this is a permissions issue:" -ForegroundColor DarkGray
+ Write-Host " Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray
+ }
+ }
+ }
+ else {
+ # No error but no data — could be legitimately empty
+ Write-Host " No data returned." -ForegroundColor DarkGray
+ if ($pInfo) {
+ Write-Host " Possible reasons:" -ForegroundColor DarkGray
+ Write-Host " - $($pInfo.Reason)" -ForegroundColor DarkGray
+ Write-Host " - Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray
+ }
+ }
+ Write-Host ""
+ continue
+ }
+
+ Write-SectionHeader $mod.Name
+
+ # Extract the displayable rows and columns per module
+ $rows = $null
+ $cols = $null
+
+ switch ($mod.Fn) {
+ 'Get-OrphanedResources' {
+ $rows = $data.Orphans
+ $cols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail')
+ }
+ 'Get-IdleVMs' {
+ $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 }
+ if ($data.IdleVMs -and @($data.IdleVMs).Count -gt 0) {
+ Write-Host " Scanned $scanned running VMs — $(@($data.IdleVMs).Count) idle/underutilized" -ForegroundColor White
+ $rows = $data.IdleVMs
+ $cols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification')
+ }
+ else {
+ Write-Host " Scanned $scanned running VMs — no idle or underutilized VMs detected" -ForegroundColor Green
+ }
+ }
+ 'Get-StorageTierAdvice' {
+ $hotCount = if ($data.TotalHotAccounts) { $data.TotalHotAccounts } else { 0 }
+ if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) {
+ Write-Host " $hotCount Hot-tier accounts scanned — $(@($data.Recommendations).Count) can be optimized" -ForegroundColor White
+ $rows = $data.Recommendations
+ $cols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation')
+ }
+ else {
+ Write-Host " $hotCount Hot-tier accounts scanned — all are appropriately tiered" -ForegroundColor Green
+ }
+ }
+ 'Get-AHBOpportunities' {
+ $rows = @()
+ if ($data.WindowsVMs) {
+ $rows += @($data.WindowsVMs) | ForEach-Object {
+ $est = if ($_.estMonthlySavings) { '$' + ('{0:N0}' -f $_.estMonthlySavings) + '/mo' } else { 'n/a' }
+ [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense; 'Est Savings' = $est }
+ }
+ }
+ if ($data.SQLVMs) {
+ $rows += @($data.SQLVMs) | ForEach-Object {
+ [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense; 'Est Savings' = '-' }
+ }
+ }
+ if ($data.SQLDatabases) {
+ $rows += @($data.SQLDatabases) | ForEach-Object {
+ [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense; 'Est Savings' = '-' }
+ }
+ }
+ $cols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License', 'Est Savings')
+ }
+ 'Get-ReservationAdvice' {
+ if ($data.AccessDenied) {
+ Show-PermissionReadout -Fn 'Get-ReservationAdvice' -PermissionInfo $permissionInfo -Activity 'Advisor / reservation recommendations'
+ }
+ else {
+ $rows = $data.AdvisorRecommendations | ForEach-Object {
+ $resLabel = if ($_.Subscription -and $_.Subscription -ne $_.SubscriptionId) { $_.Subscription }
+ elseif ($_.Solution) { $_.Solution.Substring(0, [math]::Min(50, $_.Solution.Length)) }
+ else { ($_.ResourceName -split '/')[-1] }
+ [PSCustomObject]@{
+ Resource = $resLabel
+ Type = ($_.ResourceType -split '/')[-1]
+ Term = $_.Term
+ Savings = '{0:C0}' -f [double]$_.AnnualSavings
+ Impact = $_.Impact
+ }
+ }
+ $cols = @('Resource', 'Type', 'Term', 'Savings', 'Impact')
+ if ($data.EstimatedAnnualSavings) {
+ Write-ColorizedLine -Text " Est. annual savings: $($data.EstimatedAnnualSavings.ToString('C0'))" -DefaultColor 'White'
+ }
+ }
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ Write-Host " RIs: $($data.RICount) (avg $($data.RIAvgUtilization)% util) | Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)% util)" -ForegroundColor White
+ $rows = $data.UnderutilizedRIs | ForEach-Object {
+ [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" }
+ }
+ $cols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil')
+ }
+ elseif ($data.AccessDenied) {
+ Show-PermissionReadout -Fn 'Get-CommitmentUtilization' -PermissionInfo $permissionInfo -Activity 'reservation / savings plan utilization'
+ }
+ else {
+ Write-Host " No active reservations or savings plans found." -ForegroundColor DarkGray
+ }
+ }
+ 'Get-SavingsRealized' {
+ Write-Host " Monthly savings breakdown:" -ForegroundColor White
+ Write-ColorizedLine -Text " RI: $($data.RISavingsMonthly.ToString('C0')) SP: $($data.SPSavingsMonthly.ToString('C0')) AHB: $($data.AHBSavingsMonthly.ToString('C0'))" -DefaultColor 'Cyan'
+ Write-ColorizedLine -Text " Total monthly: $($data.TotalMonthly.ToString('C0')) Annual: $($data.TotalAnnual.ToString('C0'))" -DefaultColor 'White'
+ $rows = $null # summary only
+ }
+ 'Get-CostData' {
+ # CostData is a hashtable keyed by subscription ID
+ if ($data -is [hashtable]) {
+ $rows = $data.GetEnumerator() | ForEach-Object {
+ # Prefer the name carried in the cost data itself (hub
+ # FOCUS data), then the selected-subscription lookup,
+ # then a truncated ID as a last resort.
+ $subLabel = if ($_.Value.Name) { $_.Value.Name }
+ elseif ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] }
+ else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) }
+ [PSCustomObject]@{
+ Subscription = $subLabel
+ Actual = '{0:C0}' -f [double]$_.Value.Actual
+ Forecast = '{0:C0}' -f [double]$_.Value.Forecast
+ Currency = $_.Value.Currency
+ }
+ }
+ $cols = @('Subscription', 'Actual', 'Forecast', 'Currency')
+ }
+ }
+ 'Get-ResourceCosts' {
+ $rows = @($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 50 | ForEach-Object {
+ $resName = if ($_.ResourcePath) { ($_.ResourcePath -split '/')[-1] } else { '-' }
+ [PSCustomObject]@{
+ Resource = $resName
+ ResourceGroup = $_.ResourceGroup
+ ResourceType = ($_.ResourceType -split '/')[-1]
+ Cost = '{0:C2}' -f [double]$_.Actual
+ }
+ }
+ $cols = @('Resource', 'ResourceGroup', 'ResourceType', 'Cost')
+ if (@($data).Count -gt 50) {
+ Write-Host " (showing top 50 of $(@($data).Count) resources by cost)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag -and $data.CostByTag.Count -gt 0) {
+ if ($data.Source) { Write-Host " Source: $($data.Source)" -ForegroundColor DarkGray }
+ $rows = foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ foreach ($v in $tag.Value) {
+ $displayVal = if ($v.TagValue.Length -gt 40) { $v.TagValue.Substring(0, 37) + '...' } else { $v.TagValue }
+ [PSCustomObject]@{ Tag = $tag.Key; Value = $displayVal; Cost = '{0:C0}' -f [double]$v.Cost }
+ }
+ }
+ $cols = @('Tag', 'Value', 'Cost')
+ }
+ elseif ($data.NoTagsFound) {
+ Write-Host " No tags found in environment to query cost against." -ForegroundColor DarkGray
+ }
+ else {
+ $tagCount = if ($data.TagsQueried) { $data.TagsQueried.Count } else { 0 }
+ $cbtCount = if ($data.CostByTag) { $data.CostByTag.Count } else { 0 }
+ Write-Host " Tags queried: $tagCount, results: $cbtCount — no cost data returned." -ForegroundColor DarkGray
+ if ($data.UsedTimeframe) { Write-Host " Timeframe: $($data.UsedTimeframe)" -ForegroundColor DarkGray }
+ }
+ }
+ 'Get-CostTrend' {
+ # Per-sub data only counts when a subscription actually has months
+ $nonEmptySubs = @()
+ if ($data.BySubscription -and $data.BySubscription.Count -gt 0) {
+ $nonEmptySubs = @($data.BySubscription.GetEnumerator() | Where-Object { $_.Value -and @($_.Value).Count -gt 0 })
+ }
+ $hasMonths = ($data.Months -and @($data.Months).Count -gt 0)
+
+ if ((-not $nonEmptySubs -or $nonEmptySubs.Count -eq 0) -and -not $hasMonths) {
+ Write-Host " No cost trend data returned. Requires Cost Management Reader at the subscription or MG scope, or there is no historical spend in the selected period." -ForegroundColor DarkGray
+ $rows = $null
+ $cols = $null
+ }
+ elseif ($nonEmptySubs -and $nonEmptySubs.Count -gt 0) {
+ foreach ($subEntry in $nonEmptySubs) {
+ $subName = if ($subNameLookup.ContainsKey($subEntry.Key)) { $subNameLookup[$subEntry.Key] } else { $subEntry.Key }
+ Write-Host " $subName" -ForegroundColor White
+ $subRows = $subEntry.Value | ForEach-Object {
+ [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency }
+ }
+ @($subRows) | Format-Table -AutoSize | Out-String | ForEach-Object {
+ $lines = $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() }
+ $hdrDone = $false
+ foreach ($ln in $lines) {
+ if (-not $hdrDone) {
+ if ($ln -match '^[\s\-]+$') { Write-Host " $ln" -ForegroundColor DarkCyan; $hdrDone = $true }
+ else { Write-Host " $ln" -ForegroundColor Cyan }
+ }
+ else { Write-ColorizedLine -Text " $ln" -DefaultColor 'White' }
+ }
+ }
+ }
+ # Skip default table rendering
+ $rows = $null
+ $cols = $null
+ }
+ else {
+ # Fallback: aggregate months with sub name header
+ if ($Subscriptions -and $Subscriptions.Count -gt 0) {
+ $subNames = ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', '
+ Write-Host " $subNames" -ForegroundColor White
+ }
+ $rows = $data.Months | ForEach-Object {
+ [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency }
+ }
+ $cols = @('Month', 'Cost', 'Currency')
+ }
+ }
+ 'Get-TagInventory' {
+ Write-Host " Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags" -ForegroundColor White
+ if ($data.TagNames -and $data.TagNames.Count -gt 0) {
+ $rows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count }
+ }
+ $cols = @('Tag', 'Resources', 'UniqueValues')
+ }
+ }
+ 'Get-TagRecommendations' {
+ $rows = $data.Analysis | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example }
+ }
+ $cols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example')
+ Write-Host " Compliance: $($data.CompliancePercent)%" -ForegroundColor White
+ }
+ 'Get-PolicyInventory' {
+ $hasComplianceData = if ($null -ne $data.HasComplianceData) { $data.HasComplianceData } else { (($data.TotalCompliant + $data.TotalNonCompliant) -gt 0) }
+ if ($hasComplianceData) {
+ Write-Host " Assignments: $($data.AssignmentCount) | Compliance: $($data.CompliancePct)% ($($data.TotalCompliant) compliant, $($data.TotalNonCompliant) non-compliant)" -ForegroundColor White
+ }
+ else {
+ Write-Host " Assignments: $($data.AssignmentCount) | Compliance: data unavailable (no evaluated policy states)" -ForegroundColor White
+ }
+ $rows = $data.Assignments | Select-Object -First 15 | ForEach-Object {
+ # Parse scope into a readable label
+ $scopeRaw = $_.Scope
+ $scopeLabel = if ($scopeRaw -match '/managementGroups/([^/]+)') {
+ $mgId = $Matches[1]
+ if ($mgId.Length -gt 20) { "MG: $($mgId.Substring(0,17))..." } else { "MG: $mgId" }
+ }
+ elseif ($scopeRaw -match '/resourceGroups/([^/]+)') { "RG: $($Matches[1])" }
+ elseif ($scopeRaw -match '/subscriptions/([^/]+)') {
+ $subId = $Matches[1]
+ $subName = if ($subNameLookup.ContainsKey($subId)) { $subNameLookup[$subId] } else { $subId.Substring(0, 8) + '...' }
+ "Sub: $subName"
+ }
+ else { $scopeRaw }
+ # Truncate long policy names (some embed subscription GUIDs)
+ $displayName = $_.AssignmentName
+ if ($displayName.Length -gt 60) { $displayName = $displayName.Substring(0, 57) + '...' }
+ [PSCustomObject]@{ Name = $displayName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $scopeLabel }
+ }
+ $cols = @('Name', 'Effect', 'Enforcement', 'Scope')
+ if ($data.AssignmentCount -gt 15) {
+ Write-Host " (showing 15 of $($data.AssignmentCount) assignments)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-PolicyRecommendations' {
+ $rows = $data.Analysis | ForEach-Object {
+ [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect }
+ }
+ $cols = @('Policy', 'Status', 'Category', 'Priority', 'Effect')
+ Write-Host " Compliance: $($data.CompliancePct)%" -ForegroundColor White
+ }
+ 'Get-BudgetStatus' {
+ $bSumColor = if ($data.OverBudgetCount -gt 0) { 'Red' } elseif ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }
+ Write-Host " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline
+ Write-Host "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline
+ Write-Host " | " -ForegroundColor White -NoNewline
+ Write-Host "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline
+ Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White
+ $rows = $data.Budgets | ForEach-Object {
+ [PSCustomObject]@{
+ Budget = $_.BudgetName
+ Amount = '{0:C0}' -f [double]$_.Amount
+ Spent = '{0:C0}' -f [double]$_.ActualSpend
+ PctUsed = "$($_.PctUsed)%"
+ Risk = $_.Risk
+ }
+ }
+ $cols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk')
+ }
+ 'Get-BudgetHistory' {
+ if ($data -and @($data).Count -gt 0) {
+ $rows = @($data) | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Budget = $_.BudgetName
+ Month = $_.Month
+ Budgeted = '{0:C0}' -f [double]$_.BudgetAmount
+ Actual = '{0:C0}' -f [double]$_.ActualSpend
+ PctUsed = "$($_.PctUsed)%"
+ Status = $_.Status
+ }
+ }
+ $cols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status')
+ }
+ else {
+ Write-Host " No budget history available (no budgets configured, or no cost data for the period)." -ForegroundColor DarkGray
+ }
+ }
+ 'Get-AnomalyAlerts' {
+ Write-Host " Alerts: $($data.TotalAlerts) | Anomaly: $($data.AnomalyAlertCount) | Active: $($data.ActiveAlertCount) | Rules: $($data.ConfiguredRuleCount)" -ForegroundColor White
+ $rows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object {
+ $label = if ($_.AlertLabel) { $_.AlertLabel } else { $_.AlertName }
+ if ($label.Length -gt 45) { $label = $label.Substring(0, 42) + '...' }
+ [PSCustomObject]@{ Alert = $label; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription }
+ }
+ $cols = @('Alert', 'Type', 'Status', 'Subscription')
+ }
+ 'Get-BillingStructure' {
+ $rows = $data.BillingAccounts | ForEach-Object {
+ [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus }
+ }
+ $cols = @('Account', 'Agreement', 'Type', 'Status')
+ }
+ 'Get-ContractInfo' {
+ $rows = @($data) | ForEach-Object {
+ [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus }
+ }
+ $cols = @('Account', 'Agreement', 'Type', 'Currency', 'Status')
+ }
+ 'Get-MaccCommitment' {
+ if (-not $data.Applicable) {
+ Write-Host " $($data.Reason)" -ForegroundColor DarkGray
+ }
+ elseif ($data.HasMacc -and @($data.Commitments).Count -gt 0) {
+ $rows = @($data.Commitments) | ForEach-Object {
+ [PSCustomObject]@{
+ Account = $_.BillingAccount
+ Commitment = '{0:C0}' -f [double]$_.Commitment
+ Consumed = '{0:C0}' -f [double]$_.Consumed
+ Remaining = '{0:C0}' -f [double]$_.Remaining
+ PctUsed = "$($_.PctUsed)%"
+ Status = $_.Status
+ Expires = $_.ExpirationDate
+ }
+ }
+ $cols = @('Account', 'Commitment', 'Consumed', 'Remaining', 'PctUsed', 'Status', 'Expires')
+ }
+ else {
+ Write-Host " $($data.Reason)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.EstimatedAnnualSavings) {
+ Write-ColorizedLine -Text " Est. annual savings: `$$($data.EstimatedAnnualSavings) | $($data.TotalCount) recommendations" -DefaultColor 'White'
+ }
+ $rows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Category
+ Impact = $_.Impact
+ Resource = $_.ResourceName
+ Problem = ($_.Problem -replace '(.{60}).+', '$1...')
+ Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' }
+ }
+ }
+ $cols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings')
+ if ($data.TotalCount -gt 15) {
+ Write-Host " (showing top 15 of $($data.TotalCount) by savings)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-CarbonMetrics' {
+ $arrow = if ($data.ChangeValueKg -gt 0) { 'up' } elseif ($data.ChangeValueKg -lt 0) { 'down' } else { 'flat' }
+ Write-ColorizedLine -Text " Latest month ($($data.LatestMonth)): $($data.TotalEmissionsKg) $($data.Unit) | MoM $arrow $($data.ChangeRatio)%" -DefaultColor 'White'
+ $rows = $data.BySubscription | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Emissions = "$($_.EmissionsKg) kg"
+ }
+ }
+ $cols = @('Subscription', 'Emissions')
+ }
+ 'Get-LegacyResources' {
+ Write-ColorizedLine -Text " $($data.TotalCount) legacy/retiring resources found" -DefaultColor 'White'
+ $rows = $data.LegacyResources | Select-Object -First 20 | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Category
+ Resource = $_.ResourceName
+ Detail = ($_.Detail -replace '(.{55}).+', '$1...')
+ Impact = $_.Impact
+ }
+ }
+ $cols = @('Category', 'Resource', 'Detail', 'Impact')
+ }
+ 'Get-UnitEconomics' {
+ Write-ColorizedLine -Text " Compute: $($data.Currency) $($data.ComputeCost) ($($data.ComputeSharePct)%) over $($data.VmCount) VMs / $($data.TotalVCpu) vCPU / $($data.TotalMemoryGb) GB RAM" -DefaultColor 'White'
+ Write-ColorizedLine -Text " Storage: $($data.Currency) $($data.StorageCost) ($($data.StorageSharePct)%) over $($data.TotalStorageGb) GB ($($data.DiskGb) GB disk + $($data.BlobFileGb) GB blob/file)" -DefaultColor 'White'
+ if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray }
+ $rows = @(
+ [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = "$($data.Currency) $($data.CostPerVCpu)" }
+ [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = "$($data.Currency) $($data.CostPerGbRam)" }
+ [PSCustomObject]@{ Metric = 'Cost per VM'; Value = "$($data.Currency) $($data.CostPerVm)" }
+ [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = "$($data.Currency) $($data.CostPerGb)" }
+ )
+ $cols = @('Metric', 'Value')
+ }
+ 'Get-AIWorkloadMetrics' {
+ if (-not $data.HasData) {
+ Write-Host " No AI workloads detected — AI KPIs skipped." -ForegroundColor Green
+ }
+ else {
+ $fp = $data.AIFootprint
+ Write-ColorizedLine -Text " AI footprint — OpenAI/AIServices: $($fp.OpenAIAccounts + $fp.AIServices) ML workspaces: $($fp.MLWorkspaces) AI Search: $($fp.SearchServices) GPU VMs: $($fp.GpuVmCount)" -DefaultColor 'White'
+ Write-ColorizedLine -Text " Tokens (MTD): $($data.TotalTokens) total ($($data.TotalPromptTokens) in / $($data.TotalGeneratedTokens) out) over $($data.TotalRequests) requests" -DefaultColor 'White'
+ Write-ColorizedLine -Text " AI spend (MTD): $($data.Currency) $($data.TotalAICost) | $($data.Currency) $($data.CostPer1KTokens)/1K tokens | $($data.Currency) $($data.CostPerRequest)/request" -DefaultColor 'White'
+ if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray }
+ if ($data.ByModel -and @($data.ByModel).Count -gt 0) {
+ $rows = $data.ByModel
+ $cols = @('Deployment', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'PctOfTokens')
+ }
+ elseif ($data.ByAccount -and @($data.ByAccount).Count -gt 0) {
+ $rows = $data.ByAccount
+ $cols = @('Name', 'Tokens', 'Requests', 'Cost', 'CostPer1KTokens')
+ }
+ }
+ }
+ default {
+ # Fallback: try to display as-is with first 4 properties
+ $items = @($data)
+ $sample = $items[0]
+ if ($sample.PSObject) {
+ $cols = $sample.PSObject.Properties.Name | Select-Object -First 4
+ $rows = $items
+ }
+ }
+ }
+
+ # Render the table
+ if ($rows -and @($rows).Count -gt 0) {
+ $validCols = $cols | Where-Object { $_ }
+ if ($validCols) {
+ # Budget Status: color each row by risk level
+ if ($mod.Fn -eq 'Get-BudgetStatus') {
+ $budgetRows = @($rows)
+ # Render header manually
+ $headerStr = @($budgetRows) | Select-Object $validCols | Format-Table -AutoSize | Out-String |
+ ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } }
+ if ($headerStr.Count -ge 2) {
+ Write-Host " $($headerStr[0])" -ForegroundColor Cyan
+ Write-Host " $($headerStr[1])" -ForegroundColor DarkCyan
+ }
+ # Render each data row with risk-based color
+ for ($ri = 2; $ri -lt $headerStr.Count; $ri++) {
+ $budgetLine = $headerStr[$ri]
+ $matchedBudget = $null
+ if ($ri - 2 -lt $budgetRows.Count) { $matchedBudget = $budgetRows[$ri - 2] }
+ $riskVal = if ($matchedBudget -and $matchedBudget.Risk) { $matchedBudget.Risk } else { '' }
+ $rowColor = switch ($riskVal) {
+ 'Over Budget' { 'Red' }
+ 'Forecast Over' { 'Yellow' }
+ 'At Risk' { 'Yellow' }
+ 'Watch' { 'DarkYellow' }
+ default { 'Green' }
+ }
+ Write-ColorizedLine -Text " $budgetLine" -DefaultColor $rowColor
+ }
+ }
+ else {
+ $tableLines = @($rows) | Select-Object $validCols | Format-Table -AutoSize | Out-String |
+ ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } }
+ $headerDone = $false
+ foreach ($line in $tableLines) {
+ if (-not $headerDone) {
+ # First two lines are header + separator
+ if ($line -match '^[\s\-]+$') {
+ Write-Host " $line" -ForegroundColor DarkCyan
+ $headerDone = $true
+ }
+ else {
+ Write-Host " $line" -ForegroundColor Cyan
+ }
+ }
+ else {
+ Write-ColorizedLine -Text " $line" -DefaultColor 'White'
+ }
+ }
+ }
+ }
+ }
+ elseif (-not $rows) {
+ # Module used inline Write-Host (like SavingsRealized) — no table needed
+ }
+ else {
+ Write-Host " (no findings)" -ForegroundColor DarkGray
+ }
+
+ # -- FinOps KPI Insights ---------------------------------------
+ # Map this scan's result to the FinOps Foundation KPIs it informs,
+ # with a computed value where the data allows. Reuses the same
+ # catalog + compute path in both entry points (parity).
+ if (Get-Command Get-KpiInsightsForResult -ErrorAction SilentlyContinue) {
+ $kpiInsights = @()
+ try { $kpiInsights = @(Get-KpiInsightsForResult -FunctionName $mod.Fn -Output $data) } catch { $kpiInsights = @() }
+ if ($kpiInsights.Count -gt 0) {
+ Write-Host ""
+ Write-Host " FinOps KPIs:" -ForegroundColor Cyan
+ foreach ($kpi in $kpiInsights) {
+ if ($kpi.status -eq 'computed' -and $kpi.yourValue) {
+ Write-Host " - $($kpi.kpiName): " -ForegroundColor White -NoNewline
+ Write-Host "$($kpi.yourValue)" -ForegroundColor Green -NoNewline
+ Write-Host " [$($kpi.domain)]" -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " - $($kpi.kpiName) " -ForegroundColor DarkGray -NoNewline
+ Write-Host "(informational, $($kpi.domain))" -ForegroundColor DarkGray
+ }
+ }
+ }
+ }
+
+ # -- Contextual Guidance ---------------------------------------
+ # Severity: Red = address immediately, Yellow = needs attention, Green = doing well
+ $guidanceItems = @()
+ switch ($mod.Fn) {
+ 'Get-OrphanedResources' {
+ $orphanCount = if ($data.Orphans) { @($data.Orphans).Count } else { 0 }
+ if ($orphanCount -gt 10) {
+ $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', '
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$orphanCount orphaned resources found ($categories). These generate cost with zero value." }
+ @{ Severity = 'Red'; Message = "FinOps Principle: Eliminate waste before optimizing. Orphaned resources are the easiest wins." }
+ @{ Severity = 'Yellow'; Message = "Set up Azure Policy to audit unattached disks, NICs, and public IPs to prevent future orphans." }
+ @{ Severity = 'Yellow'; Message = "Build a monthly cleanup cadence — orphans accumulate fast as teams scale up and down."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' }
+ )
+ }
+ elseif ($orphanCount -gt 0) {
+ $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', '
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$orphanCount orphaned resources found ($categories). Review and delete to reclaim spend." }
+ @{ Severity = 'Yellow'; Message = "Use Azure Policy to audit unattached disks and NICs going forward."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No orphaned resources. Environment is clean — good operational hygiene." }
+ )
+ }
+ }
+ 'Get-IdleVMs' {
+ $idleCount = if ($data.IdleVMs) { @($data.IdleVMs).Count } else { 0 }
+ $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 }
+ if ($idleCount -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$idleCount of $scanned VMs are idle or underutilized. This is likely significant wasted spend." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Check Azure Advisor for right-size recommendations before deleting — some may just need a smaller SKU." }
+ @{ Severity = 'Yellow'; Message = "For dev/test workloads, implement auto-shutdown schedules (saves 50-70% on non-production VMs)." }
+ @{ Severity = 'Yellow'; Message = "Consider Azure Spot VMs for fault-tolerant workloads — up to 90% discount vs. pay-as-you-go."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/spot-vms' }
+ )
+ }
+ elseif ($idleCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$idleCount idle VMs detected. Right-size or deallocate to reduce spend." }
+ @{ Severity = 'Yellow'; Message = "Check Advisor for SKU recommendations. Auto-shutdown schedules help for dev/test."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations#optimize-virtual-machine-spend' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All $scanned running VMs are actively utilized. Compute spend looks healthy." }
+ )
+ }
+ }
+ 'Get-StorageTierAdvice' {
+ $recoCount = if ($data.Recommendations) { @($data.Recommendations).Count } else { 0 }
+ if ($recoCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$recoCount storage accounts can be moved to a cheaper tier (Cool or Cold saves 50-75%)." }
+ @{ Severity = 'Yellow'; Message = "FinOps Principle: Match storage tier to access patterns. Most data is written once and rarely read." }
+ @{ Severity = 'Yellow'; Message = "Enable lifecycle management policies to auto-tier blobs based on last access time — set it and forget it."; Docs = 'https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All storage accounts are appropriately tiered. Good data lifecycle management." }
+ )
+ }
+ }
+ 'Get-AHBOpportunities' {
+ $ahbCount = if ($rows) { @($rows).Count } else { 0 }
+ if ($ahbCount -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$ahbCount resources eligible for Azure Hybrid Benefit — up to 40% savings on Windows, 55% on SQL licensing." }
+ @{ Severity = 'Red'; Message = "FinOps Action: AHB is one of the highest-impact, lowest-effort optimizations. Apply to all eligible VMs and SQL resources." }
+ @{ Severity = 'Yellow'; Message = "Requires Software Assurance or qualifying subscription licenses. Check with your licensing team."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' }
+ )
+ }
+ elseif ($ahbCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$ahbCount resources can use Azure Hybrid Benefit (up to 40% Windows / 55% SQL savings)." }
+ @{ Severity = 'Yellow'; Message = "Requires Software Assurance. Low effort to apply — high cost impact."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' }
+ )
+ }
+ }
+ 'Get-TagInventory' {
+ $coverage = if ($data.TagCoverage) { $data.TagCoverage } else { 0 }
+ if ($coverage -lt 30) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Tag coverage is critically low at $coverage%." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Tags are the #1 requirement for cost allocation. Without tags, you cannot do chargeback, showback, or unit economics." }
+ @{ Severity = 'Red'; Message = "Start with these 5 essential tags: CostCenter, Environment, Owner, Application, Department." }
+ @{ Severity = 'Yellow'; Message = "Use Azure Policy 'Require a tag and its value' to enforce tagging at deployment time." }
+ @{ Severity = 'Yellow'; Message = "Use 'Inherit a tag from the resource group' policy to auto-tag existing resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ elseif ($coverage -lt 50) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Tag coverage at $coverage% — below the minimum for reliable cost allocation." }
+ @{ Severity = 'Yellow'; Message = "FinOps requires 80%+ tag coverage for meaningful chargeback. Prioritize tagging high-cost resources first." }
+ @{ Severity = 'Yellow'; Message = "Essential tags: CostCenter, Environment, Owner, Application, Department." }
+ @{ Severity = 'Yellow'; Message = "Deploy tag inheritance policies to propagate subscription/RG tags to child resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ elseif ($coverage -lt 80) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Tag coverage at $coverage% — good progress, but target 80%+ for reliable cost allocation." }
+ @{ Severity = 'Yellow'; Message = "Focus on the highest-cost untagged resources. Use Cost Management views to find them." }
+ @{ Severity = 'Yellow'; Message = "Enable tag inheritance policies to auto-apply subscription/RG tags to new resources." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Tag coverage at $coverage% — strong tagging discipline." }
+ @{ Severity = 'Green'; Message = "Enable tag-based cost allocation in Cost Management to leverage your tags for chargeback." }
+ @{ Severity = 'Green'; Message = "Consider adding a 'Criticality' tag for incident response prioritization."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag -and $data.CostByTag.Count -gt 0) {
+ # Only measure untagged spend against CAF allocation tags
+ # (CostCenter, Customer, Project, Environment, ...), not
+ # identity/marker tags like FinOps or cm-resource-parent
+ # that blanket resources and skew the figure. Same tag set
+ # the FinOps KPI uses, so the guidance and the KPI agree.
+ $allocTags = if (Get-Command Get-CafAllocationTag -ErrorAction SilentlyContinue) {
+ Get-CafAllocationTag
+ }
+ else {
+ @('CostCenter', 'Customer', 'Project', 'Environment', 'Application',
+ 'Owner', 'BusinessUnit', 'Department', 'Team', 'Service', 'WorkloadName')
+ }
+ # Pick the allocation tag with the largest untagged cost
+ # (the biggest allocation gap). Each resource appears as
+ # "(untagged)" under every tag it lacks, so taking the max
+ # across tags avoids summing the same resource repeatedly.
+ $maxUntaggedCost = 0
+ $maxUntaggedTag = ''
+ foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ if ($allocTags -notcontains $tag.Key) { continue }
+ foreach ($v in $tag.Value) {
+ if ($v.TagValue -eq '(untagged)' -and [double]$v.Cost -gt $maxUntaggedCost) {
+ $maxUntaggedCost = [double]$v.Cost
+ $maxUntaggedTag = $tag.Key
+ }
+ }
+ }
+ if (-not $maxUntaggedTag) {
+ # No CAF allocation tag present to measure against
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No CAF allocation tag (CostCenter, Customer, Project, Environment, Owner, ...) is in use, so spend cannot be attributed. Add an allocation tag and deploy inheritance to make cost traceable." }
+ )
+ }
+ elseif ($maxUntaggedCost -gt 1000) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Untagged spend: $("{0:C0}" -f $maxUntaggedCost) not allocated by '$maxUntaggedTag'. This cost cannot be attributed to any team, project, or budget." }
+ @{ Severity = 'Red'; Message = "FinOps Impact: Untagged spend creates 'shadow IT' — no one owns it, no one optimizes it." }
+ @{ Severity = 'Yellow'; Message = "Use Cost Management tag views to identify the highest-cost resources missing '$maxUntaggedTag' and tag them first." }
+ )
+ }
+ elseif ($maxUntaggedCost -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Some untagged spend detected: $("{0:C0}" -f $maxUntaggedCost) not allocated by '$maxUntaggedTag'. Tag remaining resources for full cost traceability." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All scanned cost is tagged with allocation tags. Cost allocation is fully traceable — enables chargeback and showback." }
+ )
+ }
+ }
+ elseif ($data.NoTagsFound) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "No tags exist to analyze cost against. Cost allocation is impossible without tags." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Start with CostCenter, Environment, and Owner tags. These 3 enable basic chargeback/showback." }
+ @{ Severity = 'Yellow'; Message = "Run Tag Inventory first, then come back to Cost by Tag to see the financial impact."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ }
+ 'Get-CostTrend' {
+ if ($data.Months -and @($data.Months).Count -ge 2) {
+ $sorted = @($data.Months) | Sort-Object Month -Descending | Select-Object -First 2
+ $current = [double]$sorted[0].Cost
+ $previous = [double]$sorted[1].Cost
+ if ($previous -gt 0) {
+ $change = [math]::Round((($current - $previous) / $previous) * 100, 1)
+ if ($change -gt 20) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Cost spiked $change% month-over-month. Investigate immediately — this is abnormal growth." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Check for new deployments, usage spikes, or runaway auto-scale." }
+ @{ Severity = 'Yellow'; Message = "Set up Cost Management budget alerts at 80%, 90%, 100% to catch spikes early."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/cost-mgt-alerts-monitor-usage-spending' }
+ )
+ }
+ elseif ($change -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Cost increased $change% MoM. Moderate growth — review new resources deployed this period." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Establish a monthly cost review cadence to catch trends before they become problems." }
+ )
+ }
+ elseif ($change -lt -5) {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Cost decreased $([math]::Abs($change))% MoM. Optimization efforts are working." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Cost trend is stable ($change% change). Good cost discipline and predictable spend." }
+ )
+ }
+ }
+ }
+ }
+ 'Get-ReservationAdvice' {
+ if ($data.AdvisorRecommendations -and @($data.AdvisorRecommendations).Count -gt 0) {
+ $totalSavings = if ($data.EstimatedAnnualSavings) { $data.EstimatedAnnualSavings } else { 0 }
+ if ($totalSavings -gt 10000) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Significant reservation savings available: $("{0:C0}" -f $totalSavings)/year." }
+ @{ Severity = 'Red'; Message = "FinOps Principle: Commitment-based discounts (RIs, Savings Plans) are the single largest cost lever — typically 30-60% savings." }
+ @{ Severity = 'Yellow'; Message = "Start with 1-year terms for flexibility. Use shared scope to maximize utilization across subscriptions." }
+ @{ Severity = 'Yellow'; Message = "Review 14-day usage trends before purchasing to ensure steady-state workloads."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Reservation savings available: $("{0:C0}" -f $totalSavings)/year. Consider purchasing for steady-state workloads." }
+ @{ Severity = 'Yellow'; Message = "Start with 1-year terms. Use shared scope for best utilization."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ }
+ else {
+ if ($data.AccessDenied) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Access denied reading Advisor / reservation recommendations — results are blocked, not empty." }
+ @{ Severity = 'Yellow'; Message = "Required role: $($permissionInfo['Get-ReservationAdvice'].Role) at $($permissionInfo['Get-ReservationAdvice'].Scope) scope. Ask a billing/subscription admin to assign it, then re-scan."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No reservation recommendations. Current commitment coverage appears sufficient." }
+ )
+ }
+ }
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ $riUtil = if ($data.RIAvgUtilization) { [double]$data.RIAvgUtilization } else { 100 }
+ $spUtil = if ($data.SPAvgUtilization) { [double]$data.SPAvgUtilization } else { 100 }
+ $lowUtil = ($riUtil -lt 80) -or ($spUtil -lt 80)
+ $medUtil = ($riUtil -lt 95) -or ($spUtil -lt 95)
+ if ($lowUtil) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Commitment utilization below 80%. You may be paying more than on-demand pricing." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Review scope and SKU alignment. Exchange underused RIs for better-fitting ones." }
+ @{ Severity = 'Yellow'; Message = "Target 95%+ utilization. Consider switching unused RIs to Savings Plans for more flexibility."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' }
+ )
+ }
+ elseif ($medUtil) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Commitment utilization at RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%. Room to improve." }
+ @{ Severity = 'Yellow'; Message = "Review scope settings — broadening scope can improve utilization across subscriptions." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Commitment utilization is excellent (RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%). Maximum discount realized." }
+ )
+ }
+ }
+ elseif ($data.AccessDenied) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Access denied reading reservation / savings plan utilization — results are blocked, not empty." }
+ @{ Severity = 'Yellow'; Message = "Required role: $($permissionInfo['Get-CommitmentUtilization'].Role) at $($permissionInfo['Get-CommitmentUtilization'].Scope) scope. Ask a billing/subscription admin to assign it, then re-scan."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' }
+ )
+ }
+ }
+ 'Get-SavingsRealized' {
+ if ($data.TotalMonthly -and $data.TotalMonthly -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Realizing $($data.TotalMonthly.ToString('C0'))/month ($($data.TotalAnnual.ToString('C0'))/year) in commitment discounts." }
+ @{ Severity = 'Green'; Message = "FinOps Maturity: Active savings tracking shows Run-level FinOps maturity. Keep reviewing quarterly." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No savings from commitments detected. Evaluate RIs and Savings Plans for steady-state workloads." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Commitment discounts are the #1 cost optimization lever (30-60% savings)."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ }
+ 'Get-BudgetStatus' {
+ $atRisk = if ($data.AtRiskCount) { $data.AtRiskCount } else { 0 }
+ $over = if ($data.OverBudgetCount) { $data.OverBudgetCount } else { 0 }
+ $bCoverage = if ($data.BudgetCoverage) { $data.BudgetCoverage } else { 0 }
+ if ($over -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$over budget(s) exceeded. Immediate review needed — spending is above approved levels." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Identify the cause (new deployments, usage spike, missing commitment) and remediate." }
+ @{ Severity = 'Yellow'; Message = "Add action groups with alerts at 80%, 90%, 100% thresholds to catch overruns earlier next period."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' }
+ )
+ }
+ elseif ($atRisk -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$atRisk budget(s) at risk of overrun. Review forecasted spend vs. remaining budget." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Proactive budget monitoring prevents end-of-period surprises. Consider cost reduction now." }
+ )
+ }
+ elseif ($bCoverage -lt 50) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Budgets are the starting point for cost accountability. Without them, there's no alerting, no forecasting, no governance." }
+ @{ Severity = 'Yellow'; Message = "Create a budget for every subscription. Start with last month's actual spend + 10% buffer."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Budgets are healthy. All within thresholds with $bCoverage% coverage. Good financial governance." }
+ )
+ }
+ }
+ 'Get-AnomalyAlerts' {
+ $activeAlerts = if ($data.ActiveAlertCount) { $data.ActiveAlertCount } else { 0 }
+ $rules = if ($data.ConfiguredRuleCount) { $data.ConfiguredRuleCount } else { 0 }
+ if ($activeAlerts -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$activeAlerts active anomaly alerts. Review to determine if they indicate unexpected spend patterns." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Cost anomaly detection is an early warning system. Investigate anomalies promptly." }
+ )
+ }
+ elseif ($rules -eq 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No anomaly detection rules configured. Set up Cost Management anomaly alerts for early spend warnings." }
+ @{ Severity = 'Yellow'; Message = "Anomaly detection is built into Azure Cost Management at no extra cost."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/understand/analyze-unexpected-charges' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Anomaly detection is configured with $rules rule(s) and no active alerts. Monitoring is working." }
+ )
+ }
+ }
+ 'Get-PolicyInventory' {
+ $compliance = if ($data.CompliancePct) { $data.CompliancePct } else { 0 }
+ $nonCompliant = if ($data.TotalNonCompliant) { $data.TotalNonCompliant } else { 0 }
+ $hasComplianceData = if ($null -ne $data.HasComplianceData) { $data.HasComplianceData } else { (($data.TotalCompliant + $data.TotalNonCompliant) -gt 0) }
+ if (-not $hasComplianceData) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No policy compliance data available. Resource Graph 'policystates' returned no rows - Policy Insights may not have evaluated resources yet, or the identity lacks Policy Insights read access." }
+ @{ Severity = 'Yellow'; Message = "Assignments detected: $($data.AssignmentCount). Compliance percentages require evaluated policy states."; Docs = 'https://learn.microsoft.com/azure/governance/policy/how-to/get-compliance-data' }
+ )
+ }
+ elseif ($nonCompliant -gt 20) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Governance gaps are significant." }
+ @{ Severity = 'Yellow'; Message = "FinOps Governance: Use 'Deny' for critical policies (e.g., required tags). Use 'Audit' first during rollout." }
+ @{ Severity = 'Yellow'; Message = "Create remediation tasks for existing non-compliant resources."; Docs = 'https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources' }
+ )
+ }
+ elseif ($nonCompliant -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Review and remediate or create exemptions." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Full policy compliance ($compliance%). Strong governance posture." }
+ )
+ }
+ }
+ 'Get-PolicyRecommendations' {
+ if ($data.Analysis -and @($data.Analysis).Count -gt 0) {
+ $missing = @($data.Analysis | Where-Object { $_.Status -eq 'Missing' })
+ if ($missing.Count -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$($missing.Count) recommended FinOps policies are not assigned. Governance foundation is incomplete." }
+ @{ Severity = 'Yellow'; Message = "Priority policies: tag enforcement, allowed VM SKUs, allowed locations, resource naming." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Policy-driven governance prevents cost waste at deployment time — cheaper than cleanup."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' }
+ )
+ }
+ elseif ($missing.Count -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended policies not yet assigned. Review and deploy as needed." }
+ @{ Severity = 'Yellow'; Message = "Start with: tag enforcement, allowed locations, and allowed VM SKUs."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All recommended FinOps policies are assigned. Strong governance foundation." }
+ )
+ }
+ }
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) {
+ $highImpact = @($data.Recommendations | Where-Object { $_.Impact -eq 'High' })
+ if ($highImpact.Count -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$($highImpact.Count) high-impact Advisor recommendations. Significant savings available." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Start with high-impact items — they offer the largest return for effort." }
+ @{ Severity = 'Yellow'; Message = "Dismiss recommendations you've evaluated to keep the list actionable. Review monthly." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$(@($data.Recommendations).Count) Advisor recommendations ($($highImpact.Count) high-impact). Review and prioritize." }
+ @{ Severity = 'Yellow'; Message = "Dismiss evaluated items to keep the list clean. Azure Advisor refreshes daily." }
+ )
+ }
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No Advisor cost recommendations. Environment is well optimized." }
+ )
+ }
+ }
+ 'Get-CostData' {
+ if ($data -is [hashtable] -and $data.Count -gt 0) {
+ $totalActual = 0
+ foreach ($sub in $data.GetEnumerator()) { $totalActual += [double]$sub.Value.Actual }
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Current period spend: $("{0:C0}" -f $totalActual) across $($data.Count) subscription(s)." }
+ @{ Severity = 'Green'; Message = "FinOps Practice: Review actual vs. forecast regularly. Pair this data with Budget Status to track variance." }
+ )
+ }
+ }
+ 'Get-ResourceCosts' {
+ $topCount = if ($data) { @($data).Count } else { 0 }
+ if ($topCount -gt 0) {
+ $topCost = [double](@($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 1).Actual
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Top resource costs $("{0:C2}" -f $topCost). Focus optimization on the largest cost drivers." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: The top 20% of resources typically drive 80% of spend. Optimize these first." }
+ )
+ }
+ }
+ 'Get-AIWorkloadMetrics' {
+ if (-not $data.HasData) {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No AI/LLM workloads detected. No AI-specific cost optimization needed right now." }
+ )
+ }
+ elseif ($data.CostPer1KTokens -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Effective AI rate: $($data.Currency) $($data.CostPer1KTokens) per 1K tokens across $($data.TotalTokens) tokens (MTD). Track this as your core AI unit-economics KPI." }
+ @{ Severity = 'Yellow'; Message = "Compare model deployments above — shift high-volume traffic to cheaper SKUs (e.g., gpt-4o-mini) and reserve premium models for tasks that need them." }
+ @{ Severity = 'Yellow'; Message = "For steady, predictable token volume, evaluate Provisioned Throughput Units (PTUs) — they can beat pay-as-you-go at scale."; Docs = 'https://learn.microsoft.com/azure/ai-services/openai/concepts/provisioned-throughput' }
+ )
+ }
+ elseif ($data.TotalTokens -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Token usage detected ($($data.TotalTokens) MTD) but cost could not be mapped. Grant Cost Management Reader to compute cost per 1K tokens." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "AI footprint present but no token-metered usage this month. Confirm whether idle AI resources can be deprovisioned to avoid baseline cost." }
+ )
+ }
+ }
+ }
+
+ # Render guidance with severity colors
+ if ($guidanceItems.Count -gt 0) {
+ Write-Host ""
+ # Determine overall severity for the header
+ $hasCritical = $guidanceItems | Where-Object { $_.Severity -eq 'Red' }
+ $hasWarning = $guidanceItems | Where-Object { $_.Severity -eq 'Yellow' }
+ $headerColor = if ($hasCritical) { 'Red' } elseif ($hasWarning) { 'Yellow' } else { 'Green' }
+ $headerIcon = switch ($headerColor) { 'Red' { '[!]' } 'Yellow' { '[~]' } 'Green' { '[+]' } }
+ Write-Host " $headerIcon GUIDANCE" -ForegroundColor $headerColor
+
+ foreach ($item in $guidanceItems) {
+ $color = switch ($item.Severity) { 'Red' { 'Red' } 'Yellow' { 'DarkYellow' } 'Green' { 'Green' } default { 'Gray' } }
+ $icon = switch ($item.Severity) { 'Red' { '!' } 'Yellow' { '~' } 'Green' { '+' } default { '-' } }
+ Write-Host " $icon $($item.Message)" -ForegroundColor $color
+ if ($item.Docs) {
+ Write-Host " $($item.Docs)" -ForegroundColor DarkCyan
+ }
+ }
+ }
+
+ Write-Host ""
+ }
+
+ # -- Export option -------------------------------------------------
+ if ($ExportPath) {
+ $exportDir = $ExportPath
+ }
+ else {
+ $defaultPath = Join-Path (Get-Location) 'FinOpsResults'
+ Write-Host " Export results? [E] Export [Enter] Skip" -ForegroundColor DarkGray
+ $eKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ if ($eKey.Character -eq 'e' -or $eKey.Character -eq 'E') {
+ Write-Host ""
+ Write-Host " Path [$defaultPath]: " -ForegroundColor White -NoNewline
+ $exportDir = Read-Host
+ if (-not $exportDir -or $exportDir.Trim() -eq '') {
+ $exportDir = $defaultPath
+ }
+ }
+ else {
+ $exportDir = $null
+ }
+ }
+
+ if ($exportDir -and $exportDir.Trim() -ne '') {
+ if (-not (Test-Path $exportDir)) {
+ New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
+ }
+
+ # -- CSV exports per module --
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ if ($data -and @($data).Count -gt 0) {
+ $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', ''
+ $csvPath = Join-Path $exportDir "$safeName.csv"
+ $data | Export-Csv -Path $csvPath -NoTypeInformation
+ }
+ }
+
+ # -- HTML report --
+ $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
+ $subList = if ($Subscriptions) { ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' } else { 'N/A' }
+ $htmlSb = [System.Text.StringBuilder]::new()
+ [void]$htmlSb.Append(@"
+
+
+
+
+
FinOps Multitool Report — $timestamp
+
+
+
+
FinOps Multitool Report
+
Generated: $timestamp | Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))
+"@)
+
+ # Summary cards
+ $errorCount = ($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count
+ [void]$htmlSb.Append('
')
+ [void]$htmlSb.Append("
Total Findings
$totalFindings
")
+ [void]$htmlSb.Append("
Scans Run
$(($Modules | Where-Object { $_.Selected }).Count)
")
+ if ($errorCount -gt 0) {
+ [void]$htmlSb.Append("
")
+ }
+ [void]$htmlSb.Append('
')
+
+ # Per-module sections
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $fn = $mod.Fn
+ $data = $Results[$fn]
+ $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name)
+ [void]$htmlSb.Append("
$eName ")
+
+ $errorKey = "_error_$fn"
+ if ($Results.ContainsKey($errorKey)) {
+ $eMsg = [System.Net.WebUtility]::HtmlEncode($Results[$errorKey])
+ [void]$htmlSb.Append("
Error: $eMsg")
+ if ($permissionInfo.ContainsKey($fn)) {
+ $pi = $permissionInfo[$fn]
+ [void]$htmlSb.Append("Required: $([System.Net.WebUtility]::HtmlEncode($pi.Role)) at $([System.Net.WebUtility]::HtmlEncode($pi.Scope)) scope ($([System.Net.WebUtility]::HtmlEncode($pi.API))) ")
+ }
+ [void]$htmlSb.Append('
')
+ continue
+ }
+
+ if (-not $data -or @($data).Count -eq 0) {
+ $noDataMsg = 'No data returned.'
+ if ($permissionInfo.ContainsKey($fn)) {
+ $noDataMsg += " $($permissionInfo[$fn].Reason)"
+ }
+ [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode($noDataMsg))
")
+ continue
+ }
+
+ # Render module-specific summaries + table
+ $htmlRows = $null
+ $htmlCols = $null
+ switch ($fn) {
+ 'Get-OrphanedResources' {
+ $htmlRows = $data.Orphans
+ $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail')
+ }
+ 'Get-IdleVMs' {
+ [void]$htmlSb.Append("
Scanned $($data.ScannedVMs) running VMs
")
+ $htmlRows = $data.IdleVMs
+ $htmlCols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification')
+ }
+ 'Get-StorageTierAdvice' {
+ [void]$htmlSb.Append("
$($data.TotalHotAccounts) Hot-tier accounts scanned
")
+ $htmlRows = $data.Recommendations
+ $htmlCols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation')
+ }
+ 'Get-AHBOpportunities' {
+ $ahbRows = @()
+ if ($data.WindowsVMs) { $ahbRows += @($data.WindowsVMs) | ForEach-Object { $est = if ($_.estMonthlySavings) { '$' + ('{0:N0}' -f $_.estMonthlySavings) + '/mo' } else { 'n/a' }; [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense; 'Est Savings' = $est } } }
+ if ($data.SQLVMs) { $ahbRows += @($data.SQLVMs) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense; 'Est Savings' = '-' } } }
+ if ($data.SQLDatabases) { $ahbRows += @($data.SQLDatabases) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense; 'Est Savings' = '-' } } }
+ $htmlRows = $ahbRows
+ $htmlCols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License', 'Est Savings')
+ }
+ 'Get-TagInventory' {
+ [void]$htmlSb.Append("
Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags
")
+ if ($data.TagNames) {
+ $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count }
+ }
+ $htmlCols = @('Tag', 'Resources', 'UniqueValues')
+ }
+ }
+ 'Get-CostData' {
+ if ($data -is [hashtable]) {
+ $htmlRows = $data.GetEnumerator() | ForEach-Object {
+ $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key }
+ [PSCustomObject]@{ Subscription = $sl; Actual = '{0:C0}' -f [double]$_.Value.Actual; Forecast = '{0:C0}' -f [double]$_.Value.Forecast; Currency = $_.Value.Currency }
+ }
+ $htmlCols = @('Subscription', 'Actual', 'Forecast', 'Currency')
+ }
+ }
+ 'Get-ResourceCosts' {
+ $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | Select-Object -First 50 | ForEach-Object {
+ [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = '{0:C2}' -f [double]$_.Actual }
+ }
+ $htmlCols = @('ResourceGroup', 'ResourceType', 'Cost')
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag) {
+ $htmlRows = foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ foreach ($v in $tag.Value) {
+ [PSCustomObject]@{ Tag = $tag.Key; Value = $v.TagValue; Cost = '{0:C0}' -f [double]$v.Cost }
+ }
+ }
+ $htmlCols = @('Tag', 'Value', 'Cost')
+ }
+ }
+ 'Get-CostTrend' {
+ if ($data.Months) {
+ $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } }
+ $htmlCols = @('Month', 'Cost', 'Currency')
+ }
+ }
+ 'Get-ReservationAdvice' {
+ if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("
Est. annual savings: `$$($data.EstimatedAnnualSavings.ToString('N0'))
") }
+ $htmlRows = $data.AdvisorRecommendations | ForEach-Object {
+ [PSCustomObject]@{ Resource = ($_.ResourceName -split '/')[-1]; Type = ($_.ResourceType -split '/')[-1]; Term = $_.Term; Savings = '{0:C0}' -f [double]$_.AnnualSavings; Impact = $_.Impact }
+ }
+ $htmlCols = @('Resource', 'Type', 'Term', 'Savings', 'Impact')
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ [void]$htmlSb.Append("
RIs: $($data.RICount) (avg $($data.RIAvgUtilization)%) | Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)%)
")
+ $htmlRows = $data.UnderutilizedRIs | ForEach-Object { [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" } }
+ $htmlCols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil')
+ }
+ }
+ 'Get-SavingsRealized' {
+ [void]$htmlSb.Append("
RI: $($data.RISavingsMonthly.ToString('C0')) | SP: $($data.SPSavingsMonthly.ToString('C0')) | AHB: $($data.AHBSavingsMonthly.ToString('C0')) | Total: $($data.TotalMonthly.ToString('C0'))/mo
")
+ }
+ 'Get-BudgetStatus' {
+ [void]$htmlSb.Append("
Budgets: $($data.TotalBudgets) | At risk: $($data.AtRiskCount) | Over budget: $($data.OverBudgetCount) | Coverage: $($data.BudgetCoverage)%
")
+ $htmlRows = $data.Budgets | ForEach-Object {
+ $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'Forecast Over' { 'severity-yellow' } 'At Risk' { 'severity-yellow' } 'Watch' { 'severity-yellow' } default { 'severity-green' } }
+ [PSCustomObject]@{ Budget = $_.BudgetName; Amount = '{0:C0}' -f [double]$_.Amount; Spent = '{0:C0}' -f [double]$_.ActualSpend; PctUsed = "$($_.PctUsed)%"; Risk = $_.Risk; _riskClass = $riskClass }
+ }
+ $htmlCols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk')
+ }
+ 'Get-AnomalyAlerts' {
+ [void]$htmlSb.Append("
Total: $($data.TotalAlerts) | Anomaly: $($data.AnomalyAlertCount) | Active: $($data.ActiveAlertCount)
")
+ $htmlRows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object {
+ $label = if ($_.AlertLabel) { $_.AlertLabel } else { $_.AlertName }
+ [PSCustomObject]@{ Alert = $label; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription }
+ }
+ $htmlCols = @('Alert', 'Type', 'Status', 'Subscription')
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("
Est. annual savings: `$$($data.EstimatedAnnualSavings) | $($data.TotalCount) recommendations
") }
+ $htmlRows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 25 | ForEach-Object {
+ [PSCustomObject]@{ Category = $_.Category; Impact = $_.Impact; Resource = $_.ResourceName; Problem = ($_.Problem -replace '(.{80}).+', '$1...'); Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' } }
+ }
+ $htmlCols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings')
+ }
+ 'Get-TagRecommendations' {
+ $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example } }
+ $htmlCols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example')
+ }
+ 'Get-PolicyInventory' {
+ $htmlRows = $data.Assignments | ForEach-Object { [PSCustomObject]@{ Name = $_.AssignmentName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $_.Scope } }
+ $htmlCols = @('Name', 'Effect', 'Enforcement', 'Scope')
+ }
+ 'Get-PolicyRecommendations' {
+ $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } }
+ $htmlCols = @('Policy', 'Status', 'Category', 'Priority', 'Effect')
+ }
+ 'Get-BillingStructure' {
+ $htmlRows = $data.BillingAccounts | ForEach-Object { [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } }
+ $htmlCols = @('Account', 'Agreement', 'Type', 'Status')
+ }
+ 'Get-ContractInfo' {
+ $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } }
+ $htmlCols = @('Account', 'Agreement', 'Type', 'Currency', 'Status')
+ }
+ }
+
+ # Render HTML table
+ if ($htmlRows -and $htmlCols) {
+ [void]$htmlSb.Append('
')
+ foreach ($c in $htmlCols) { [void]$htmlSb.Append("$([System.Net.WebUtility]::HtmlEncode($c)) ") }
+ [void]$htmlSb.Append(' ')
+ foreach ($r in $htmlRows) {
+ [void]$htmlSb.Append('')
+ foreach ($c in $htmlCols) {
+ $val = $r.$c
+ $enc = [System.Net.WebUtility]::HtmlEncode([string]$val)
+ # Colorize money values and risk/severity
+ if ($enc -match '^\$') { $enc = "$enc " }
+ if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc " }
+ if ($c -eq 'Impact') {
+ $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } }
+ $enc = "$enc "
+ }
+ [void]$htmlSb.Append("$enc ")
+ }
+ [void]$htmlSb.Append(' ')
+ }
+ [void]$htmlSb.Append('
')
+ }
+
+ # Render guidance
+ # (Re-evaluate guidance items for HTML — reuse the same logic)
+ }
+
+ [void]$htmlSb.Append('
')
+ [void]$htmlSb.Append('')
+
+ $htmlPath = Join-Path $exportDir 'FinOpsReport.html'
+ $htmlSb.ToString() | Out-File -FilePath $htmlPath -Encoding utf8
+
+ # Summary text file
+ $summaryPath = Join-Path $exportDir 'ScanSummary.txt'
+ $summaryLines = @(
+ "FinOps Multitool Scan Summary"
+ "Generated: $timestamp"
+ "Subscriptions: $subList"
+ "Total findings: $totalFindings"
+ ""
+ )
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $count = if ($Results[$mod.Fn]) { @($Results[$mod.Fn]).Count } else { 0 }
+ $errorKey = "_error_$($mod.Fn)"
+ $status = if ($Results.ContainsKey($errorKey)) { "ERROR: $($Results[$errorKey])" } elseif ($count -eq 0) { "No data" } else { "$count findings" }
+ $summaryLines += "$($mod.Name): $status"
+ }
+ $summaryLines | Out-File -FilePath $summaryPath -Encoding utf8
+
+ Write-Host ""
+ Write-Host " Exported to: $exportDir" -ForegroundColor Green
+ $csvCount = (Get-ChildItem $exportDir -Filter '*.csv').Count
+ Write-Host " Files: $csvCount CSVs + FinOpsReport.html + ScanSummary.txt" -ForegroundColor DarkGray
+ }
+
+ # Interactive drill-down
+ Write-Host ""
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ Write-Host " Results are stored in `$FinOpsResults. Examples:" -ForegroundColor DarkGray
+ Write-Host ' $FinOpsResults["Get-OrphanedResources"] | Format-Table' -ForegroundColor DarkGray
+ Write-Host ' $FinOpsResults["Get-IdleVMs"] | Where-Object Impact -eq "High"' -ForegroundColor DarkGray
+ Write-Host ""
+
+ return $Results
+ }
+
+ # =====================================================================
+ # MAIN FLOW
+ # =====================================================================
+ Show-Banner
+
+ # Step 1: Connect & pick subscription
+ $subs = Select-Subscription -PreselectedId $SubscriptionId
+ if (-not $subs) {
+ Write-Host " Cancelled." -ForegroundColor Yellow
+ return
+ }
+
+ # Capture tenant ID from current context
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # Step 2: Pick data source
+ $dataSource = Select-DataSource -TenantId $tenantId -Subscriptions $subs
+
+ # If "Resource Graph only", disable cost modules
+ $costModuleFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend',
+ 'Get-SavingsRealized', 'Get-CommitmentUtilization', 'Get-ReservationAdvice',
+ 'Get-BudgetStatus', 'Get-AnomalyAlerts', 'Get-BillingStructure', 'Get-ContractInfo')
+ if ($dataSource.Source -eq 'GraphOnly') {
+ foreach ($mod in $scanModules) {
+ if ($mod.Fn -in $costModuleFns) { $mod.Selected = $false }
+ }
+ }
+
+ # Show active data source
+ $sourceLabel = switch ($dataSource.Source) {
+ 'Hub' { "FinOps Hub ($($dataSource.HubStorage.name))" }
+ 'API' { 'Cost Management API (real-time)' }
+ 'GraphOnly' { 'Resource Graph only (no cost data)' }
+ }
+ $sourceColor = switch ($dataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } }
+ Write-Host ""
+ Write-Host " Data source: $sourceLabel" -ForegroundColor $sourceColor
+ Write-Host ""
+
+ # Step 3: Pick scans
+ $finalModules = Select-ScanModules -Modules $scanModules
+ if (-not $finalModules) {
+ Write-Host " Cancelled." -ForegroundColor Yellow
+ return
+ }
+
+ # Auto-enable dependencies
+ $selected = $finalModules | Where-Object { $_.Selected }
+ $selectedFns = $selected.Fn
+ $deps = @{
+ 'Get-CostByTag' = @('Get-CostData', 'Get-TagInventory')
+ 'Get-TagRecommendations' = @('Get-TagInventory')
+ 'Get-PolicyRecommendations' = @('Get-PolicyInventory')
+ 'Get-BudgetStatus' = @('Get-CostData')
+ 'Get-BudgetHistory' = @('Get-BudgetStatus', 'Get-CostTrend')
+ }
+ foreach ($depEntry in $deps.GetEnumerator()) {
+ if ($depEntry.Key -in $selectedFns) {
+ foreach ($req in $depEntry.Value) {
+ if ($req -notin $selectedFns) {
+ $mod = $finalModules | Where-Object { $_.Fn -eq $req }
+ if ($mod) {
+ $mod.Selected = $true
+ Write-Host " Auto-enabled: $($mod.Name) (required by $($depEntry.Key -replace 'Get-',''))" -ForegroundColor DarkGray
+ }
+ }
+ }
+ }
+ }
+
+ # Step 4: Run
+ $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $dataSource
+
+ # Step 5: Summary + export
+ $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs
+
+ Write-Host " Done. Results available in `$FinOpsResults" -ForegroundColor Green
+ Write-Host ""
+}
+
+# Auto-invoke when run directly (not dot-sourced or imported as module)
+if ($MyInvocation.InvocationName -ne '.') {
+ Invoke-FinOpsMultitool @PSBoundParameters
+}
diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md
new file mode 100644
index 000000000..b327b8b96
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/README.md
@@ -0,0 +1,400 @@
+# FinOps multitool — Terminal UI (TUI)
+
+Interactive terminal interface for running FinOps scans against Azure subscriptions. No GUI dependencies — works in any terminal on Windows, macOS, and Linux.
+
+## Quick Start
+
+```powershell
+# From the FinOpsMultitool directory
+Import-Module .\FinOpsMultitool.psm1
+Invoke-FinOpsMultitool
+```
+
+Or target a specific subscription:
+
+```powershell
+Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000'
+```
+
+## Requirements
+
+| Requirement | Details |
+| --------------------- | --------------------------------------------------------------- |
+| PowerShell | 5.1+ (Windows) or 7+ (cross-platform) |
+| Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` |
+| Azure RBAC | Reader + Cost Management Reader on target scope |
+| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account |
+
+Install Az modules if needed:
+
+```powershell
+Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser
+```
+
+## How It Works
+
+### 1. Authentication
+
+On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan.
+
+### 2. Data Source Selection
+
+If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source:
+
+| Source | Description |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| **FinOps Hub** | Reads cost data from the FinOps Hub. Faster, no API throttling. Tag and cost-by-tag scans are instant. |
+| **Cost Management API** | Queries the Cost Management REST API in real-time. Slower but always current. Hub tag data is still used when available. |
+| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). |
+
+When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) and pushes aggregation into the engine, returning only summarized results. This is the scalable path for large customer datasets — it never loads the raw cost rows into PowerShell. See [FinOps Hub data paths](#finops-hub-data-paths) below. The storage-export reader remains as a small-dataset fallback.
+
+### 3. Scan Selection
+
+Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure.
+
+| Key | Action |
+| --------- | ------------------ |
+| `↑` / `↓` | Navigate scan list |
+| `Space` | Toggle scan on/off |
+| `A` | Select all |
+| `N` | Deselect all |
+| `Enter` | Run selected scans |
+| `Q` | Quit |
+
+### 4. Scan Execution
+
+Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second.
+
+### 5. Results
+
+Results display inline with formatted tables, severity-colored guidance, and permission diagnostics.
+
+**Guidance system** — After each scan result, contextual FinOps guidance appears with severity-based coloring:
+
+| Icon | Color | Meaning |
+| ----- | ------ | ---------------------------------- |
+| `[!]` | Red | Critical finding — action required |
+| `[~]` | Yellow | Warning — improvement recommended |
+| `[+]` | Green | Healthy — good practices confirmed |
+
+Guidance includes FinOps Foundation best practices, actionable next steps, and links to Microsoft Learn documentation.
+
+**Dollar colorization** — All dollar amounts in results are highlighted in green for quick scanning. Budget rows are colored by risk severity (red for over budget, yellow for at risk, green for on track).
+
+**Permission diagnostics** — When a scan returns no data, the TUI explains why:
+
+- **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API
+- **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured")
+
+An optional CSV/JSON export saves to the output path.
+
+## Required Permissions
+
+Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions.
+
+| Category | Scans | Required Role | Scope |
+| ------------ | ------------------------------------------------------------ | ------------------------ | ------------------- |
+| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription |
+| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription |
+| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or MG |
+| Commitments | Reservation Advice, Commitment Utilization, Savings Realized | Cost Management Reader | Subscription |
+| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription |
+| Advisor | Optimization Advice | Reader | Subscription |
+| Account | Billing Structure, Contract Info | Billing Reader | Billing Account |
+| Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account |
+
+## Available Scans
+
+### Optimization (Resource Graph)
+
+| Scan | What it finds |
+| ------------------- | --------------------------------------------------------------------- |
+| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs |
+| Idle VMs | VMs with <5% CPU over 30 days |
+| Storage Tier Advice | Blob storage that could move to cooler tiers |
+| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit |
+| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) |
+
+### Governance
+
+| Scan | What it finds |
+| ---------------------- | --------------------------------------------------------- |
+| Tag Inventory | All tags across resources — names, values, coverage % |
+| Tag Recommendations | Inconsistent casing, similar names, missing standard tags |
+| Policy Inventory | Azure Policy assignments with scope and compliance |
+| Policy Recommendations | Gaps in policy coverage for cost governance |
+
+### Cost Analysis
+
+| Scan | What it finds |
+| -------------- | --------------------------------------------------------------------------------------------------- |
+| Cost Data | Monthly spend per subscription |
+| Resource Costs | Top resources by cost |
+| Cost by Tag | Spend breakdown by tag key/value |
+| Cost Trend | Month-over-month spend comparison |
+| Unit Economics | Cost per vCPU, per GB RAM, per VM, and per GB stored (disk + blob/file, with compute/storage split) |
+
+### AI & ML
+
+| Scan | What it finds |
+| ------------------- | -------------------------------------------------------------------------------------------------- |
+| AI Workload Metrics | Detects AI workloads, then token consumption by model, AI spend, cost per 1K tokens, cost per call |
+
+### Commitments
+
+| Scan | What it finds |
+| ---------------------- | ---------------------------------------- |
+| Reservation Advice | RI purchase recommendations from Advisor |
+| Commitment Utilization | RI and Savings Plan usage rates |
+| Savings Realized | Actual savings from existing commitments |
+
+### Monitoring
+
+| Scan | What it finds |
+| -------------- | --------------------------------- |
+| Budget Status | Budget consumption vs. thresholds |
+| Anomaly Alerts | Recent cost anomaly detections |
+
+### Sustainability
+
+| Scan | What it finds |
+| -------------- | ------------------------------------------------------------------------------------------- |
+| Carbon Metrics | Cloud carbon emissions, month-over-month change, 12-month trend, per-subscription breakdown |
+
+### Advisor & Account
+
+| Scan | What it finds |
+| ------------------- | ---------------------------------------- |
+| Optimization Advice | Azure Advisor cost recommendations |
+| Billing Structure | Account hierarchy and enrollment details |
+| Contract Info | Agreement type, offer, support plan |
+
+## FinOps KPI Coverage
+
+The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). Each scan answers a KPI question directly, and the `finops-multitool` agent skill routes a natural-language question to the matching investigation. A few examples of question → output:
+
+### Percentage of Legacy Resource → legacy resources
+
+> "Which of my resources are running on legacy or retiring SKUs?"
+
+```
+Legacy / Retiring Resources — 47 found across 156 subscriptions
+
+By category:
+ Legacy v1 VM families 18 (Basic_A / Standard_A0-A7 / D / DS / G)
+ Unmanaged VHD disks 9 (migrate to managed disks)
+ HDD Standard_LRS ≥128GB 11 (upgrade to Premium SSD)
+ Basic SKU Public IPs 6 (retiring Sep 2025 → Standard)
+ Basic SKU Load Balancers 3 (retiring Sep 2025 → Standard)
+```
+
+Legacy % = 47 ÷ total resources in scope.
+
+### Cost per Gigabyte Stored / Hourly Cost per CPU Core → unit economics
+
+> "What's my cost per vCPU and per GB of storage this month?"
+
+```
+Unit Economics — Month to Date (USD)
+
+Compute $128,400 (75.7%) 312 VMs / 1,840 vCPU / 7,360 GB RAM
+Storage $ 41,200 (24.3%) 126,400 GB (84,600 GB disk + 41,800 GB blob/file)
+
+ Cost per vCPU $69.78 / month
+ Cost per GB RAM $17.45 / month
+ Cost per VM $411.54 / month
+ Cost per GB stored $0.326 / month
+```
+
+vCPU and RAM are exact (read from Compute SKU capabilities). Storage GB combines provisioned managed disks with Storage-account used capacity (Azure Monitor `UsedCapacity`). Cost is scoped to the selected subscriptions and falls back to per-subscription queries when the management-group scope is not accessible, so the section is never silently $0. Directly produces `Cost per GB Stored`; feeds `Hourly Cost per CPU Core` (÷ 730) and `Effective Avg Compute Cost per Core`.
+
+### Token Consumption / Cost per 1K Tokens / Cost per API Call → ai workloads
+
+> "What are my AI/LLM workloads costing per token and per request this month?"
+
+This scan is self-gating: a single Resource Graph query detects whether any AI workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) exist. Non-AI tenants skip the deep scan entirely, so the scan stays fast. When AI is present, it joins Azure Monitor token metrics to Cost Management spend over the same month-to-date window.
+
+```
+AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0
+Tokens (MTD): 412,800,000 total (288,100,000 in / 124,700,000 out) over 1,240,500 requests
+AI spend (MTD): USD 3,910.42 | USD 0.0095 /1K tokens | USD 0.00315 /request
+
+Deployment PromptTokens GeneratedTokens TotalTokens PctOfTokens
+gpt-4o 210,400,000 98,200,000 308,600,000 74.8
+gpt-4o-mini 77,700,000 26,500,000 104,200,000 25.2
+```
+
+Produces `Token Consumption`, `Cost per 1K Tokens` (effective blended rate), and `Cost per API Call`; the per-model breakdown highlights where to shift traffic to cheaper SKUs or evaluate Provisioned Throughput Units (PTUs).
+
+Like the cost scans, this honors `dataSource` (`auto` / `hub` / `api`). When a readable FinOps Hub export covers the scope, AI spend and billed token volume are read straight from the export — no Azure Monitor or Cost Management calls. Cost per request is only available on the live API path, since request counts are not billed line items.
+
+### Carbon per Unit of Spend / Carbon Efficiency → carbon
+
+> "Show my cloud carbon footprint and how it changed month over month."
+
+```
+Carbon Emissions — latest available month: 2026-04 (data lags ~2 mo)
+
+Total emissions 18,420 kgCO2e
+Previous month 20,110 kgCO2e
+Change -1,690 kgCO2e (-8.4%) ↓ improving
+
+Top emitting subscriptions:
+ Production-East 00000000… 7,910 kgCO2e
+ Data-Platform a1b2c3d4… 4,330 kgCO2e
+```
+
+Combined with cost data, `Carbon per Unit of Spend` = total emissions ÷ monthly spend.
+
+### Commitment Utilization Score / % Discount Waste → commitment utilization
+
+> "How well are my reservations and savings plans being used?"
+
+```
+Commitment Utilization — trailing 30 days
+
+Reserved Instances 94.2% utilized ($3,120 unused)
+Savings Plans 88.7% utilized ($1,540 unused)
+Overall score 91.8%
+```
+
+`Commitment Utilization Score` = 91.8%; `% Commitment Discount Waste` = 100 − 91.8 = 8.2%.
+
+### % Costs from Untagged Resources → cost by tag
+
+> "How much of my spend is on untagged resources?"
+
+```
+Cost by Tag — Month to Date
+
+Tagged spend $612,300 (87.4%)
+Untagged spend $ 88,200 (12.6%) ← KPI
+```
+
+`% Costs from Untagged Resources` = 12.6%.
+
+## FinOps Hub Integration
+
+When a Hub is detected, the tool reads FinOps Hub cost data. This enables:
+
+- **Instant tag scans** — Tag Inventory and Cost by Tag are answered from Hub data instead of querying the Cost Management API
+- **No API throttling** — avoids Cost Management API rate limits
+- **Richer tag data** — Hub data contains the full Tags per cost record, enabling accurate per-resource tag parsing
+- **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals (storage path)
+- **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage
+
+### FinOps Hub data paths
+
+The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOps Hub three ways, in priority order. The first two push aggregation **into the engine** and bring back only summarized results — they never load the raw cost rows into PowerShell, so they scale to large customer datasets (tens of GB / hundreds of millions of rows):
+
+| Path | When | How |
+| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. |
+| **Kusto — offline (ftklocal)** | Your own hardware / air-gapped: an [ftklocal](https://github.com/microsoft/finops-toolkit) Kusto emulator with the exports loaded into the local **Hub** database | Set `FINOPS_HUB_KUSTO_URI` (and optionally `FINOPS_HUB_KUSTO_DB`, default `Hub`). The local emulator is queried anonymously — same KQL, no auth. |
+| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. |
+
+Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered cluster, else the storage reader. To force the live Cost Management API instead, choose the **Cost Management API** source in the TUI.
+
+#### Environment variables
+
+| Variable | Effect | Default |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
+| `FINOPS_HUB_KUSTO_URI` | Kusto cluster query URI. An `https://...kusto.windows.net` cluster (token auth) or `http://localhost:
` ftklocal emulator (anonymous). | unset (auto-discover) |
+| `FINOPS_HUB_KUSTO_DB` | Hub database name. | `Hub` |
+
+Hub data is loaded once at startup and reused across all scans that need it.
+
+## Scripting (Non-Interactive)
+
+The scan modules can be called directly without the TUI:
+
+```powershell
+Import-Module .\FinOpsMultitool.psm1
+
+# Run a single scan
+$tags = Get-TagInventory -Subscriptions $subs -TenantId $tid
+
+# Read Hub data and convert
+$hubData = Read-FinOpsHubData -StorageAccountName 'myhub' -ResourceGroupName 'rg-hub' -Months 1
+$tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData
+$costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames
+```
+
+## Write Safety (Remediation Tools)
+
+The remediation functions are read-only by default. Every write previews first, and enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`. The gate lives in the functions themselves, so it applies to any caller — the TUI, a script, or anything that imports the module.
+
+### Modes — `FINOPS_WRITE_MODE`
+
+| Mode | Behavior | Use for |
+| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
+| `ReadOnly` | **Default.** All write tools are blocked; read scans still work. Set `FINOPS_WRITE_MODE` to `Interactive` or `Enforced` to enable writes. | Locked-down or audit-only deployments (and the default) |
+| `Interactive` | `apply=true` runs the change directly. A preview/token is offered but not required. The client (human or AI) is the gate. | Platform-agnostic AI chat — low friction, any client |
+| `Enforced` | `apply=true` is **rejected** unless it carries the exact single-use token from that change's own dry-run preview (bound to a SHA-256 fingerprint, expires in 5 min). | Autonomous / unattended agents — server is the gate |
+
+Every write previews first: call the tool without `apply` to get the exact REST call, the resource evidence, and (in Enforced mode) a `confirmationToken` to pass back with `apply=true`.
+
+### Guardrails (enforced in every mode)
+
+These never depend on a well-behaved client. Configure via environment variables:
+
+| Variable | Effect | Default |
+| ----------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
+| `FINOPS_PROTECTED_TAGS` | Resources carrying any of these tag keys are never written to | `do-not-delete`, `DoNotDelete`, `lock`, `protected` |
+| `FINOPS_PROTECTED_RGS` | Resource groups (supports `*` wildcards) that are off-limits | none |
+| `FINOPS_PROTECTED_SUBS` | Subscriptions that are off-limits | none |
+| `FINOPS_WRITE_MAX_IMPACT` | Block any single write whose estimated monthly $ impact exceeds this cap (`0` = no cap) | `0` |
+| `FINOPS_WRITE_MAX_PER_WINDOW` | Max writes allowed per rolling window (blast-radius limit) | unlimited |
+| `FINOPS_WRITE_WINDOW_MIN` | Length of that window in minutes | `60` |
+| `FINOPS_AUDIT_LOG` | Path for the append-only audit log (every preview / apply / block is recorded as JSON) | `%LOCALAPPDATA%\FinOpsMultitool\finops-multitool-audit.log` |
+
+### Example — autonomous, locked-down server
+
+```json
+{
+ "servers": {
+ "finops-multitool": {
+ "type": "stdio",
+ "command": "pwsh",
+ "env": {
+ "FINOPS_WRITE_MODE": "Enforced",
+ "FINOPS_PROTECTED_RGS": "rg-prod-*,rg-shared",
+ "FINOPS_WRITE_MAX_PER_WINDOW": "5"
+ }
+ }
+ }
+}
+```
+
+In this configuration an agent must preview each change, pass the matching token back, stay out of protected resource groups, and is capped at five writes per hour — all enforced by the server, not the client.
+
+## File Structure
+
+```
+FinOpsMultitool/
+├── README.md # This file
+├── FinOpsMultitool.psm1 # Module loader (dot-sources all scan modules)
+├── Invoke-FinOpsMultitool.ps1 # TUI entry point
+├── modules/
+│ ├── helpers/
+│ │ ├── Read-FinOpsHubData.ps1 # Hub storage reader + converters (small-dataset path)
+│ │ ├── Invoke-FOHubKustoQuery.ps1 # Hub Kusto REST transport (ADX/Fabric/ftklocal)
+│ │ ├── Get-FOHubProvider.ps1 # Scalable hub provider (discovery + engine-side cost intents)
+│ │ ├── Get-PlainAccessToken.ps1 # Token helper
+│ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic
+│ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper
+│ │ ├── Confirm-WriteAction.ps1 # Write-safety policy gate (modes, guardrails, audit)
+│ │ └── MgCostScope.ps1 # Management group scope state
+│ ├── Initialize-Scanner.ps1
+│ ├── Get-CostData.ps1
+│ ├── Get-ResourceCosts.ps1
+│ ├── Get-TagInventory.ps1
+│ ├── Get-CostByTag.ps1
+│ ├── Get-OrphanedResources.ps1
+│ ├── Remove-OrphanedResource.ps1 # Write: delete orphaned resource (gated)
+│ ├── Enable-HybridBenefit.ps1 # Write: enable Azure Hybrid Benefit (gated)
+│ ├── Stop-IdleVm.ps1 # Write: deallocate idle VM (gated)
+│ ├── Get-IdleVMs.ps1
+│ └── ... # One file per scan module
+```
diff --git a/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit
new file mode 100644
index 000000000..c915de039
Binary files /dev/null and b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit differ
diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json
new file mode 100644
index 000000000..115a27e25
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json
@@ -0,0 +1,308 @@
+{
+ "_comment": "FinOps Foundation KPI correlation catalog (Phase 1). Curated subset of https://www.finops.org/finops-kpis/ that the scan output can inform. Each entry maps one or more source tools to a KPI. 'compute' = the server can calculate a value; 'informational' = the scan relates to the KPI but the value needs the field below or external input. Keep this honest: never claim a value we cannot derive.",
+ "version": "1.0.0",
+ "learnMoreBase": "https://www.finops.org/finops-kpis/",
+ "kpis": [
+ {
+ "id": "cost-per-gb-stored",
+ "name": "Cost per Gigabyte Stored",
+ "domain": "Quantify",
+ "definition": "Average cost per GB stored, influenced by storage tiers and data life-cycle management.",
+ "sourceTool": "scan_unit_economics",
+ "compute": true,
+ "field": "CostPerGb",
+ "unit": "per GB / month",
+ "plainLanguage": "What you pay for each GB of stored data this month.",
+ "exploreHint": "Run scan_storage_tier_advice to see if moving cold data to Cool or Archive lowers this."
+ },
+ {
+ "id": "hourly-cost-per-cpu-core",
+ "name": "Hourly Cost per CPU Core",
+ "domain": "Quantify",
+ "definition": "Average cost per CPU core, giving unit-cost insight for compute.",
+ "sourceTool": "scan_unit_economics",
+ "compute": true,
+ "field": "CostPerVCpu",
+ "divideBy": 730,
+ "unit": "per vCPU / hour",
+ "plainLanguage": "Roughly what one CPU core costs you per hour.",
+ "exploreHint": "Run scan_optimization_advice or scan_idle_vms to find rightsizing that lowers this."
+ },
+ {
+ "id": "effective-avg-compute-cost-per-core",
+ "name": "Effective Average Compute Cost per Core",
+ "domain": "Quantify",
+ "definition": "Average cost per core per month after amortizing unused commitment discounts.",
+ "sourceTool": "scan_unit_economics",
+ "compute": true,
+ "field": "CostPerVCpu",
+ "unit": "per vCPU / month",
+ "plainLanguage": "Average monthly cost of one CPU core across your fleet.",
+ "exploreHint": "Pair with scan_commitment_utilization to amortize unused reservation/savings-plan cost."
+ },
+ {
+ "id": "pct-costs-untagged",
+ "name": "Percentage of Costs Associated with Untagged Resources",
+ "domain": "Understand",
+ "definition": "Percentage of cloud cost on resources missing a required tag, per your tagging policy.",
+ "sourceTool": "scan_cost_by_tag",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How much of your spend lands on resources that are not tagged.",
+ "exploreHint": "Run scan_tag_recommendations to see which CAF tags to backfill and where."
+ },
+ {
+ "id": "pct-costs-unallocated",
+ "name": "Percentage of Costs Associated with Unallocated Resources",
+ "domain": "Understand",
+ "definition": "Percentage of spend that cannot be attributed to a team, project, or application.",
+ "sourceTool": "scan_cost_by_tag",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "Share of your bill you cannot yet assign to an owner.",
+ "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run scan_cost_by_tag."
+ },
+ {
+ "id": "tagging-policy-compliant",
+ "name": "Percentage of Costs that are Tagging Policy Compliant",
+ "domain": "Manage",
+ "definition": "Share of cost on resources that satisfy your organizational tagging policy.",
+ "sourceTool": "scan_cost_by_tag",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How much of your spend is on properly tagged resources.",
+ "exploreHint": "Run scan_policy_recommendations to deploy tag-enforcement policy."
+ },
+ {
+ "id": "commitment-utilization-score",
+ "name": "Commitment Utilization Score",
+ "domain": "Optimize",
+ "definition": "Burndown of pre-purchased commitment capacity against actual consumption. ~100% is healthy; lower signals shelfware.",
+ "sourceTool": "scan_commitment_utilization",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How much of your reserved/committed capacity you are actually using.",
+ "exploreHint": "Run scan_reservation_advice to right-size future commitments."
+ },
+ {
+ "id": "pct-commitment-discount-waste",
+ "name": "Percentage of Commitment Discount Waste",
+ "domain": "Optimize",
+ "definition": "Percentage of commitment capacity not applied to on-demand spend (wasted).",
+ "sourceTool": "scan_commitment_utilization",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "The slice of your reservations/savings plans you paid for but did not use.",
+ "exploreHint": "100% minus your utilization. Investigate underused commitments for renewal changes."
+ },
+ {
+ "id": "pct-compute-covered-by-commitment",
+ "name": "Percent of Compute Spend Covered by Commitment Discounts",
+ "domain": "Optimize",
+ "definition": "Percentage of compute cost (excluding Spot) covered by commitment discounts.",
+ "sourceTool": "scan_savings_realized",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How much of your eligible spend rides on a discounted commitment (reservation or savings plan) versus full on-demand. Computed from amortized cost by pricing model, excluding Spot from the eligible base.",
+ "exploreHint": "Raise coverage by buying reservations or savings plans for steady-state workloads."
+ },
+ {
+ "id": "percent-unused-resources",
+ "name": "Percent of Unused Resources",
+ "domain": "Optimize",
+ "definition": "Measure of unused resources such as unattached disks, idle NICs, and orphaned public IPs.",
+ "sourceTool": "scan_orphaned_resources",
+ "compute": true,
+ "unit": "count / cost",
+ "plainLanguage": "Resources you are paying for that nothing is using. Shown as the orphaned-resource count found by the scan.",
+ "exploreHint": "Run remediate_delete_orphaned_resource (gated) to clean them up."
+ },
+ {
+ "id": "computational-waste",
+ "name": "Computational Waste Percentage",
+ "domain": "Optimize",
+ "definition": "Spend that provided zero business utility, e.g. idle resources before suspension or over-provisioning.",
+ "sourceTool": "scan_idle_vms",
+ "compute": true,
+ "unit": "count / cost",
+ "plainLanguage": "Compute you are running and paying for but barely using. Shown as the share of running VMs flagged idle or underutilized.",
+ "exploreHint": "Run remediate_deallocate_vm (gated, reversible) on confirmed idle VMs."
+ },
+ {
+ "id": "percent-storage-frequent-tier",
+ "name": "Percent Storage on Frequent Access Tier",
+ "domain": "Optimize",
+ "definition": "Percentage of object storage held on a frequent (Hot) access tier.",
+ "sourceTool": "scan_storage_tier_advice",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "How much of your storage sits on the most expensive Hot tier.",
+ "exploreHint": "Flagged Hot accounts with low activity are candidates for Cool or Archive."
+ },
+ {
+ "id": "storage-decay-ratio",
+ "name": "Storage Decay Ratio",
+ "domain": "Optimize",
+ "definition": "Storage cost attributed to data not accessed within a set window (dark data).",
+ "sourceTool": "scan_storage_tier_advice",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "Share of storage spend on data nobody has touched recently.",
+ "exploreHint": "Low-activity accounts in this scan approximate dark data; apply lifecycle policy."
+ },
+ {
+ "id": "budget-burn-rate",
+ "name": "CSP Cloud Budget Burn Rate",
+ "domain": "Manage",
+ "definition": "The rate at which an organization is consuming its allocated cloud budget.",
+ "sourceTool": "scan_budget_status",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How fast you are spending against your budget this period. Shown as the average percent of budget consumed across all budgets.",
+ "exploreHint": "Risk levels in the scan flag budgets trending over. Adjust thresholds or spend."
+ },
+ {
+ "id": "variance-budget-vs-actual",
+ "name": "Percentage Variance of Budgeted vs. Actual Spend",
+ "domain": "Manage",
+ "definition": "Difference between budgeted cost and actual cost incurred.",
+ "sourceTool": "scan_budget_status",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How far your actual spend is from what you planned. Shown as total actual vs total budgeted across all budgets.",
+ "exploreHint": "Compare budget amount vs actual in the scan rows."
+ },
+ {
+ "id": "anomaly-detection-rate",
+ "name": "Anomaly Detection Rate",
+ "domain": "Manage",
+ "definition": "Frequency and cost impact of detected spending anomalies.",
+ "sourceTool": "scan_anomaly_alerts",
+ "compute": true,
+ "unit": "count",
+ "plainLanguage": "How often unusual cost spikes are being caught. Shown as a proxy: anomaly alerts triggered plus the number of detection rules configured (a true rate needs the count of anomalies that occurred, which Azure does not expose).",
+ "exploreHint": "Configure anomaly alert rules so spikes are caught proactively."
+ },
+ {
+ "id": "effective-savings-rate",
+ "name": "Effective Savings Rate Percentage",
+ "domain": "Quantify",
+ "definition": "Return-on-investment metric across all commitment discounts and optimizations.",
+ "sourceTool": "scan_savings_realized",
+ "compute": true,
+ "unit": "%",
+ "plainLanguage": "How much you are actually saving versus paying full on-demand rates. Shown as realized monthly savings from RIs, Savings Plans and AHB (a true rate also needs total on-demand-equivalent spend).",
+ "exploreHint": "Realized savings from RIs, Savings Plans and AHB appear in this scan."
+ },
+ {
+ "id": "pct-legacy-resource",
+ "name": "Percentage of Legacy Resource",
+ "domain": "Optimize",
+ "definition": "Percentage of resources running on older generation types vs modern equivalents.",
+ "sourceTool": "scan_legacy_resources",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "How much of your estate runs on older, less efficient resource types.",
+ "exploreHint": "Modernizing to newer SKUs usually improves price-performance."
+ },
+ {
+ "id": "carbon-per-unit-spend",
+ "name": "Carbon per Unit of Cloud Spend",
+ "domain": "Quantify",
+ "definition": "Carbon emissions (CO2e) per unit of cloud spend, ideally drillable by service category.",
+ "sourceTool": "scan_carbon",
+ "compute": false,
+ "unit": "CO2e / spend",
+ "plainLanguage": "How much carbon each dollar of cloud spend produces.",
+ "exploreHint": "Carbon-efficient regions and modern SKUs reduce this."
+ },
+ {
+ "id": "token-consumption-metrics",
+ "name": "Token Consumption Metrics",
+ "domain": "Quantify",
+ "definition": "Cost of token-based models based on input/output token usage.",
+ "sourceTool": "scan_ai_workloads",
+ "compute": true,
+ "unit": "tokens / cost",
+ "plainLanguage": "How many tokens your AI workloads burn and what they cost.",
+ "exploreHint": "Prompt engineering and model choice move this number."
+ },
+ {
+ "id": "cost-per-api-call",
+ "name": "Cost per API Call",
+ "domain": "Quantify",
+ "definition": "Average cost for each API call made to AI services.",
+ "sourceTool": "scan_ai_workloads",
+ "compute": true,
+ "unit": "per call",
+ "plainLanguage": "What each AI service request costs you on average.",
+ "exploreHint": "High per-call cost can signal an inefficient model or call pattern."
+ },
+ {
+ "id": "pct-unallocated-shared-cost",
+ "name": "Percentage of Unallocated Shared Cost",
+ "domain": "Understand",
+ "definition": "Shared expenses that cannot be directly attributed to a specific team or project.",
+ "sourceTool": "scan_allocate_shared_cost",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "Shared platform cost not yet split across its consumers.",
+ "exploreHint": "Use scan_allocate_shared_cost or scan_usage_allocation to split it."
+ },
+ {
+ "id": "allocation-accuracy-index",
+ "name": "Allocation Accuracy Index (AAI)",
+ "domain": "Understand",
+ "definition": "Percentage of total cost directly and accurately attributed to responsible owners.",
+ "sourceTool": "scan_usage_allocation",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "How reliably your costs map to the right team or project.",
+ "exploreHint": "Improve tagging and shared-cost rules to raise this."
+ },
+ {
+ "id": "tco-per-workload",
+ "name": "Total Cost of Ownership per Workload",
+ "domain": "Quantify",
+ "definition": "Average full-lifecycle cost of running an individual workload (compute, storage, network, etc.).",
+ "sourceTool": "scan_vm_cost_breakdown",
+ "compute": false,
+ "unit": "cost / workload",
+ "plainLanguage": "The true all-in cost of one workload, not just its compute line.",
+ "exploreHint": "scan_vm_cost_breakdown decomposes a VM into all its billed meters."
+ },
+ {
+ "id": "frequency-of-data-updates",
+ "name": "Frequency of Data Updates",
+ "domain": "Manage",
+ "definition": "Time between updates of cost data (e.g. since the last export refresh).",
+ "sourceTool": "detect_cost_data_source",
+ "compute": false,
+ "unit": "freshness date",
+ "plainLanguage": "How current the cost data behind your answers is.",
+ "exploreHint": "The Freshness date in this tool is your data-update recency."
+ },
+ {
+ "id": "cost-visibility-delay",
+ "name": "Cost Visibility Delay",
+ "domain": "Manage",
+ "definition": "Time between a cost occurring and it being ingested, normalized, and visible.",
+ "sourceTool": "detect_cost_data_source",
+ "compute": false,
+ "unit": "days",
+ "plainLanguage": "The lag between spending money and seeing it in reports.",
+ "exploreHint": "Export-based reads are typically a day behind; live API is near-real-time."
+ },
+ {
+ "id": "unified-cost-usage-visibility",
+ "name": "Unified Cost & Usage Visibility",
+ "domain": "Understand",
+ "definition": "Extent to which all relevant cost and usage sources are integrated into one reporting system.",
+ "sourceTool": "detect_cost_data_source",
+ "compute": false,
+ "unit": "%",
+ "plainLanguage": "How much of your estate is covered by one unified cost view.",
+ "exploreHint": "CoveragePct in this tool reflects how much of scope the hub/export covers."
+ }
+ ]
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1
new file mode 100644
index 000000000..fc7f70e31
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1
@@ -0,0 +1,179 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# ENABLE-HYBRIDBENEFIT.PS1
+# AZURE FINOPS MULTITOOL - Enable Azure Hybrid Benefit on a VM
+###########################################################################
+# Purpose: Turn on Azure Hybrid Benefit (AHB) for a single VM found by
+# scan_ahb_opportunities. AHB applies existing Windows Server /
+# SQL licenses to cut compute licensing cost up to ~85%.
+#
+# Description:
+# REVERSIBLE, savings-only write (you can set the license back to None).
+# 1. Validates the resource id (Microsoft.Compute/virtualMachines only).
+# 2. Reads the VM, detects OS, and picks the right licenseType
+# (Windows -> Windows_Server) unless one is passed explicitly.
+# 3. No-ops if AHB is already enabled.
+# 4. Routes through the configurable write-safety gate (dry-run by
+# default; token required only in Enforced mode).
+#
+# ── Parameters ──────────────────────────────────────────────────────
+# ResourceId Full ARM resource ID of the VM
+# LicenseType Override: Windows_Server | Windows_Client |
+# RHEL_BYOS | SLES_BYOS (auto-detected if omitted)
+# Apply Omitted = dry-run preview. Present = PATCH the VM.
+# ConfirmationToken Required only in Enforced mode (from the preview)
+#
+# Usage: Enable-HybridBenefit -ResourceId [-Apply]
+###########################################################################
+
+function Enable-HybridBenefit {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')]
+ param(
+ [Parameter(Mandatory)]
+ [string]$ResourceId,
+
+ [Parameter()]
+ [ValidateSet('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS')]
+ [string]$LicenseType,
+
+ [Parameter()]
+ [switch]$Apply,
+
+ [Parameter()]
+ [string]$ConfirmationToken
+ )
+
+ $apiVersion = '2024-07-01'
+
+ if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = 'This tool only enables AHB on Microsoft.Compute/virtualMachines. Pass a VM resource ID.'
+ }
+ }
+ $vmName = $Matches.name
+ $path = "$ResourceId`?api-version=$apiVersion"
+
+ # ---- Read the VM ----
+ $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET'
+ $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 }
+ if ($getStatus -eq 404) {
+ return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' }
+ }
+ if ($getStatus -lt 200 -or $getStatus -ge 300) {
+ $gErr = $null
+ if ($getResp -and $getResp.Content) { try { $gErr = ($getResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $getResp.Content } }
+ return [PSCustomObject]@{ HasData = $false; Error = "Could not read the VM (HTTP $getStatus). $gErr"; ResourceId = $ResourceId }
+ }
+
+ $vm = $null
+ try { $vm = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {}
+ $props = if ($vm) { $vm.properties } else { $null }
+ $osType = if ($props -and $props.storageProfile -and $props.storageProfile.osDisk) { [string]$props.storageProfile.osDisk.osType } else { '' }
+ $currentLicense = if ($props -and $props.licenseType) { [string]$props.licenseType } else { 'None' }
+ $location = if ($vm) { $vm.location } else { $null }
+
+ # ---- Decide the target license type ----
+ if (-not $LicenseType) {
+ if ($osType -ieq 'Windows') { $LicenseType = 'Windows_Server' }
+ elseif ($osType -ieq 'Linux') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "VM '$vmName' is Linux. AHB for Linux requires the exact distro license (RHEL_BYOS or SLES_BYOS). Re-run with an explicit licenseType only if this VM is RHEL/SLES BYOS-eligible."
+ ResourceId = $ResourceId
+ CurrentLicense = $currentLicense
+ }
+ }
+ else {
+ return [PSCustomObject]@{ HasData = $false; Error = "Could not determine OS type for '$vmName'. Pass licenseType explicitly."; ResourceId = $ResourceId }
+ }
+ }
+
+ # ---- Already enabled? No-op. ----
+ if ($currentLicense -ieq $LicenseType) {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'NoOp'
+ Applied = $false
+ Note = "AHB already enabled on '$vmName' (licenseType=$currentLicense). Nothing to do."
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ }
+ }
+
+ $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null }
+ $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null }
+ $tagHash = @{}
+ if ($vm -and $vm.tags) { foreach ($t in $vm.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } }
+
+ # ---- Write-safety gate (reversible, savings-only -> impact 0) ----
+ $decision = Resolve-WriteDecision -ToolName 'remediate_enable_hybrid_benefit' -Operation 'EnableAHB' `
+ -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash `
+ -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken `
+ -FingerprintExtra @{ licenseType = $LicenseType }
+
+ if ($decision.Decision -eq 'Blocked') {
+ return [PSCustomObject]@{
+ HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason
+ GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode
+ ResourceId = $ResourceId; ResourceName = $vmName
+ }
+ }
+
+ $bodyJson = @{ properties = @{ licenseType = $LicenseType } } | ConvertTo-Json -Depth 5
+
+ if ($decision.Decision -eq 'Preview') {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'DryRun'
+ Applied = $false
+ WriteMode = $decision.Mode
+ Warning = "PREVIEW ONLY - the VM was not changed. This is REVERSIBLE and reduces licensing cost. $($decision.Reason)"
+ Method = 'PATCH'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ Location = $location
+ OsType = $osType
+ CurrentLicense = $currentLicense
+ NewLicense = $LicenseType
+ RequestBody = ($bodyJson | ConvertFrom-Json)
+ ConfirmationToken = $decision.ConfirmationToken
+ RequiresToken = $decision.RequiresToken
+ NextStep = if ($decision.RequiresToken) {
+ 'Enforced mode: re-run with apply=true AND confirmationToken=.'
+ }
+ else { 'Re-run remediate_enable_hybrid_benefit with apply=true to enable AHB.' }
+ }
+ }
+
+ # ---- Proceed: PATCH the VM ----
+ Write-Host " Enabling Azure Hybrid Benefit ($LicenseType) on '$vmName'..." -ForegroundColor Yellow
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PATCH' -Payload $bodyJson
+ $status = if ($resp) { [int]$resp.StatusCode } else { 0 }
+ $ok = $status -in @(200, 201, 202)
+ $errMsg = $null
+ if (-not $ok) {
+ $errMsg = "PATCH returned $status."
+ if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} }
+ }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'Apply'
+ Applied = $ok
+ WriteMode = $decision.Mode
+ StatusCode = $status
+ Warning = if ($ok) { "AHB enabled ($LicenseType). Reversible - set licenseType back to None to undo." } else { $null }
+ Error = $errMsg
+ Method = 'PATCH'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ CurrentLicense = $currentLicense
+ NewLicense = $LicenseType
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1
new file mode 100644
index 000000000..86c80ec03
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1
@@ -0,0 +1,113 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-AHBOPPORTUNITIES.PS1
+# AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection
+###########################################################################
+# Purpose: Use Resource Graph to find VMs and SQL resources that are NOT
+# using Azure Hybrid Benefit (AHB) but could be. AHB saves up
+# to 85% on Windows Server and SQL Server licensing costs.
+#
+# Eligible resources:
+# - Windows VMs without licenseType = 'Windows_Server'
+# - SQL Server VMs without licenseType = 'AHUB'
+# - SQL Databases/Managed Instances without licenseType = 'BasePrice'
+#
+# Reference: https://learn.microsoft.com/en-us/azure/azure-sql/azure-hybrid-benefit
+###########################################################################
+
+function Get-AHBOpportunities {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # -- Windows VMs without AHB ----------------------------------------
+ $windowsVMs = @()
+ try {
+ Write-Host " Scanning Windows VMs for AHB eligibility..." -ForegroundColor Cyan
+ $vmQuery = @"
+resources
+| where type == 'microsoft.compute/virtualmachines'
+| where properties.storageProfile.osDisk.osType =~ 'Windows'
+| where isempty(properties.licenseType) or (properties.licenseType !~ 'Windows_Server' and properties.licenseType !~ 'Windows_Client')
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ currentLicense = coalesce(tostring(properties.licenseType), 'None'),
+ osType = tostring(properties.storageProfile.imageReference.offer)
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $windowsVMs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- Estimate per-VM AHB monthly savings (per-SKU Windows license premium) --
+ if ($windowsVMs.Count -gt 0 -and (Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue)) {
+ foreach ($vm in $windowsVMs) {
+ $est = $null
+ $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location
+ if ($rates) { $est = [math]::Round($rates.HourlyPremium * 730, 2) }
+ $vm | Add-Member -NotePropertyName estMonthlySavings -NotePropertyValue $est -Force
+ }
+ }
+
+ # -- SQL Server VMs without AHB -------------------------------------
+ $sqlVMs = @()
+ try {
+ Write-Host " Scanning SQL Server VMs for AHB eligibility..." -ForegroundColor Cyan
+ $sqlVMQuery = @"
+resources
+| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines'
+| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'),
+ sqlEdition = tostring(properties.sqlImageSku)
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000
+ $sqlVMs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- SQL Databases without AHB --------------------------------------
+ $sqlDBs = @()
+ try {
+ Write-Host " Scanning SQL Databases for AHB eligibility..." -ForegroundColor Cyan
+ $sqlDBQuery = @"
+resources
+| where type == 'microsoft.sql/servers/databases'
+| where sku.tier != 'Free' and name != 'master'
+| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'),
+ sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name)),
+ maxSizeGB = tolong(properties.maxSizeBytes) / 1073741824
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000
+ $sqlDBs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- Summary --------------------------------------------------------
+ $totalOpportunities = $windowsVMs.Count + $sqlVMs.Count + $sqlDBs.Count
+ $ahbVMSavings = ($windowsVMs | Where-Object { $_.estMonthlySavings } | Measure-Object -Property estMonthlySavings -Sum).Sum
+ if (-not $ahbVMSavings) { $ahbVMSavings = 0 }
+
+ return [PSCustomObject]@{
+ WindowsVMs = $windowsVMs
+ SQLVMs = $sqlVMs
+ SQLDatabases = $sqlDBs
+ TotalOpportunities = $totalOpportunities
+ EstMonthlyVMSavings = [math]::Round($ahbVMSavings, 2)
+ Summary = "Found $($windowsVMs.Count) Windows VMs, $($sqlVMs.Count) SQL VMs, $($sqlDBs.Count) SQL DBs eligible for AHB"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1
new file mode 100644
index 000000000..ed1deb7cd
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1
@@ -0,0 +1,365 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-AIWORKLOADMETRICS.PS1
+# AZURE FINOPS MULTITOOL - AI/LLM Workload KPIs (token economics)
+###########################################################################
+# Purpose: Detect AI workloads up front with one cheap ARG query, and -
+# only when AI is present - pull historical token usage from
+# Azure Monitor metrics and join it to Cost Management spend to
+# compute AI/LLM unit-economics KPIs (cost per 1K tokens, cost
+# per request, token volume by model).
+###########################################################################
+# Notes:
+# - Gate-first: if no AI footprint exists, the module returns after a
+# single ARG call (~50ms) so non-AI tenants pay almost nothing.
+# - Token metrics: ProcessedPromptTokens (input), GeneratedTokens
+# (output), TokenTransaction (total inference), AzureOpenAIRequests
+# (call count), split by ModelDeploymentName. Window is month-to-date
+# so it aligns 1:1 with the MonthToDate amortized cost query.
+# - RBAC: Reader (ARG + Azure Monitor metrics) + Cost Management Reader
+# (billing/MG scope) to map tokens to spend.
+# - KPIs covered: Token Consumption, Cost per API Call, Cost per 1K
+# tokens (effective rate). Cost per Inference is a request-count proxy.
+# - Export path: when -HubData (FOCUS rows from a FinOps Hub export) is
+# supplied, AI spend and billed token volume are read from the export
+# instead of the Monitor + Cost Management APIs - zero live cost calls.
+# Request counts are not billed line items, so cost-per-request is only
+# available on the live API path.
+###########################################################################
+
+function Get-AIWorkloadMetrics {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [object[]]$HubData
+ )
+
+ Write-Host " Detecting AI workloads..." -ForegroundColor Cyan
+
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+
+ # -- 0: Cheap presence gate (one ARG call) ----------------------------
+ # Count AI resources by type/kind. If nothing is found we bail out
+ # immediately so non-AI tenants never run the expensive metrics + cost
+ # loop below.
+ $openAiAccounts = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $aiServiceCount = 0
+ $mlWorkspaceCount = 0
+ $searchCount = 0
+ $gpuVmCount = 0
+
+ try {
+ $gateQuery = @"
+resources
+| extend lkind = tolower(tostring(kind))
+| where type =~ 'microsoft.cognitiveservices/accounts'
+ or type =~ 'microsoft.machinelearningservices/workspaces'
+ or type =~ 'microsoft.search/searchservices'
+ or (type =~ 'microsoft.compute/virtualmachines'
+ and tostring(properties.hardwareProfile.vmSize) matches regex @'(?i)^Standard_N')
+| project id, name, type, lkind, subscriptionId, location
+"@
+ $result = Search-AzGraphSafe -Query $gateQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+
+ foreach ($r in $rows) {
+ switch -Wildcard ([string]$r.type) {
+ 'microsoft.cognitiveservices/accounts' {
+ # OpenAI + AIServices accounts expose token metrics.
+ if ($r.lkind -match 'openai' -or $r.lkind -match 'aiservices') {
+ [void]$openAiAccounts.Add([PSCustomObject]@{
+ Id = [string]$r.id
+ Name = [string]$r.name
+ SubscriptionId = [string]$r.subscriptionId
+ Location = [string]$r.location
+ })
+ }
+ else { $aiServiceCount++ }
+ }
+ 'microsoft.machinelearningservices/workspaces' { $mlWorkspaceCount++ }
+ 'microsoft.search/searchservices' { $searchCount++ }
+ 'microsoft.compute/virtualmachines' { $gpuVmCount++ }
+ }
+ }
+ }
+ catch {
+ Write-Warning " AI detection query failed: $($_.Exception.Message)"
+ }
+
+ $footprint = [PSCustomObject]@{
+ OpenAIAccounts = $openAiAccounts.Count
+ AIServices = $aiServiceCount
+ MLWorkspaces = $mlWorkspaceCount
+ SearchServices = $searchCount
+ GpuVmCount = $gpuVmCount
+ }
+
+ $anyAI = ($openAiAccounts.Count + $aiServiceCount + $mlWorkspaceCount + $searchCount + $gpuVmCount) -gt 0
+ if (-not $anyAI) {
+ Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray
+ return [PSCustomObject]@{
+ HasData = $false
+ AIFootprint = $footprint
+ ScannedSubs = $Subscriptions.Count
+ Note = 'No AI workloads detected in the scanned subscriptions.'
+ }
+ }
+
+ Write-Host (" AI footprint - OpenAI/AIServices: {0} ML: {1} Search: {2} GPU VMs: {3}" -f `
+ ($openAiAccounts.Count + $aiServiceCount), $mlWorkspaceCount, $searchCount, $gpuVmCount) -ForegroundColor Gray
+
+ # -- 1: Historical token usage (Azure Monitor metrics) ----------------
+ # Month-to-date so it lines up with the MonthToDate cost query. Daily
+ # interval keeps the payload to ~30 datapoints per account.
+ $now = (Get-Date).ToUniversalTime()
+ $monthStart = (Get-Date -Year $now.Year -Month $now.Month -Day 1 -Hour 0 -Minute 0 -Second 0).ToUniversalTime()
+ $fromStr = $monthStart.ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $toStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ $modelTokens = @{} # deployment -> @{ Prompt; Generated; Total }
+ $acctTokens = @{} # resourceId(lower) -> @{ Tokens; Requests }
+ $totalPrompt = 0.0
+ $totalGen = 0.0
+ $totalTokens = 0.0
+ $totalReq = 0.0
+ $metricsOk = $false
+
+ $aiCost = 0.0
+ $currency = 'USD'
+ $costByAcct = @{} # resourceId(lower) -> cost
+ $costOk = $false
+
+ # -- Export path: derive tokens + cost from a FinOps Hub export -------
+ # When FOCUS rows are supplied we read AI spend and billed token volume
+ # straight from the export - no Azure Monitor and no Cost Management API
+ # calls. Request counts are not billed line items, so cost-per-request
+ # is left unavailable on this path.
+ $fromHub = $false
+ $hubApprox = $false
+ if ($HubData -and @($HubData).Count -gt 0) {
+ $agg = ConvertTo-AIHubAggregates -HubData $HubData
+ if ($agg -and ($agg.HasCost -or $agg.HasTokens)) {
+ $fromHub = $true
+ $hubApprox = $agg.Approximate
+ $modelTokens = $agg.ModelTokens
+ $acctTokens = $agg.AcctTokens
+ $costByAcct = $agg.CostByAcct
+ $totalPrompt = $agg.TotalPrompt
+ $totalGen = $agg.TotalGen
+ $totalTokens = $agg.TotalTokens
+ $aiCost = $agg.AICost
+ $currency = $agg.Currency
+ $metricsOk = $agg.HasTokens
+ $costOk = $agg.HasCost
+ Write-Host (" Derived from FinOps Hub export: {0} token rows, {1} {2} AI spend" -f `
+ $agg.RowCount, [math]::Round($agg.AICost, 2), $agg.Currency) -ForegroundColor Gray
+ }
+ }
+
+ if (-not $fromHub -and $openAiAccounts.Count -gt 0) {
+ $token = $null
+ $armBase = Get-FinOpsArmEndpoint
+ try { $token = (Get-AzAccessToken -ResourceUrl $armBase).Token } catch { }
+
+ if ($token) {
+ $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' }
+ $metricNames = 'ProcessedPromptTokens,GeneratedTokens,TokenTransaction,AzureOpenAIRequests'
+ $idx = 0
+ $acctCount = $openAiAccounts.Count
+
+ foreach ($acct in $openAiAccounts) {
+ $idx++
+ if ($acctCount -gt 5 -and ($idx -eq 1 -or $idx % [math]::Max(1, [int]($acctCount / 5)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Reading AI token metrics ($idx/$acctCount accounts)..."
+ }
+ }
+
+ $acctKey = $acct.Id.ToLowerInvariant()
+ if (-not $acctTokens.ContainsKey($acctKey)) {
+ $acctTokens[$acctKey] = @{ Name = $acct.Name; Tokens = 0.0; Requests = 0.0 }
+ }
+
+ # Split by deployment so per-model KPIs are available. The
+ # filter literal needs a single-quoted segment to keep the
+ # '$filter' token and the '*' from being touched by PowerShell.
+ $filterSeg = '&$filter=' + [uri]::EscapeDataString("ModelDeploymentName eq '*'")
+ $metricUri = "$armBase$($acct.Id)/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=$metricNames×pan=$fromStr/$toStr&aggregation=Total&interval=P1D$filterSeg"
+
+ try {
+ $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 20 -ErrorAction Stop
+ $data = $resp.Content | ConvertFrom-Json
+ $metricsOk = $true
+
+ foreach ($metric in $data.value) {
+ $mName = $metric.name.value
+ foreach ($ts in $metric.timeseries) {
+ # Deployment name comes from the splitting dimension.
+ $deployment = 'unknown'
+ if ($ts.metadatavalues) {
+ foreach ($mv in $ts.metadatavalues) {
+ if ($mv.name.value -match '(?i)ModelDeploymentName') { $deployment = [string]$mv.value }
+ }
+ }
+ $sum = 0.0
+ foreach ($dp in $ts.data) { if ($null -ne $dp.total) { $sum += [double]$dp.total } }
+
+ if (-not $modelTokens.ContainsKey($deployment)) {
+ $modelTokens[$deployment] = @{ Prompt = 0.0; Generated = 0.0; Total = 0.0 }
+ }
+ switch ($mName) {
+ 'ProcessedPromptTokens' { $modelTokens[$deployment].Prompt += $sum; $totalPrompt += $sum; $acctTokens[$acctKey].Tokens += $sum }
+ 'GeneratedTokens' { $modelTokens[$deployment].Generated += $sum; $totalGen += $sum; $acctTokens[$acctKey].Tokens += $sum }
+ 'TokenTransaction' { $modelTokens[$deployment].Total += $sum; $totalTokens += $sum }
+ 'AzureOpenAIRequests' { $totalReq += $sum; $acctTokens[$acctKey].Requests += $sum }
+ }
+ }
+ }
+ }
+ catch {
+ # Metrics unavailable for this account (no usage yet, or
+ # not an OpenAI-capable kind) - skip it.
+ }
+ }
+ }
+ }
+
+ # When TokenTransaction is sparse, fall back to prompt + generated.
+ if ($totalTokens -le 0) { $totalTokens = $totalPrompt + $totalGen }
+
+ # -- 2: Map tokens to AI spend (Cost Management) ----------------------
+ # Skipped entirely on the export path - spend already came from the Hub.
+ if (-not $fromHub) {
+ try {
+ $mgScopeId = Resolve-CostMgId -TenantId $TenantId
+ if ($mgScopeId) {
+ $rtFilter = @{ dimensions = @{ name = 'ResourceType'; operator = 'In'; values = @('microsoft.cognitiveservices/accounts') } }
+ $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions
+ $filterNode = if ($subFilter) { @{ and = @($subFilter, $rtFilter) } } else { $rtFilter }
+
+ $body = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'ResourceId' })
+ filter = $filterNode
+ }
+ } | ConvertTo-Json -Depth 12
+
+ $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body
+
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $cdata = $resp.Content | ConvertFrom-Json
+ if ($cdata.properties.rows) {
+ $costOk = $true
+ # Column order follows properties.columns; resolve indices.
+ $cols = @($cdata.properties.columns.name)
+ $iCost = [array]::IndexOf($cols, 'Cost')
+ $iRes = [array]::IndexOf($cols, 'ResourceId')
+ $iCur = [array]::IndexOf($cols, 'Currency')
+ foreach ($row in $cdata.properties.rows) {
+ $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { [double]$row[0] }
+ $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' }
+ if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] }
+ $aiCost += $amount
+ if ($rid) { $costByAcct[$rid.ToLowerInvariant()] = $amount }
+ }
+ }
+ }
+ }
+ }
+ catch {
+ Write-Warning " AI cost query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- 3: KPIs ----------------------------------------------------------
+ $costPer1kTokens = if ($totalTokens -gt 0) { [math]::Round(($aiCost / $totalTokens) * 1000, 4) } else { 0 }
+ $costPerRequest = if ($totalReq -gt 0) { [math]::Round($aiCost / $totalReq, 4) } else { 0 }
+
+ $byModel = @(
+ $modelTokens.GetEnumerator() | ForEach-Object {
+ $mt = if ($_.Value.Total -gt 0) { $_.Value.Total } else { $_.Value.Prompt + $_.Value.Generated }
+ [PSCustomObject]@{
+ Deployment = $_.Key
+ PromptTokens = [long]$_.Value.Prompt
+ GeneratedTokens = [long]$_.Value.Generated
+ TotalTokens = [long]$mt
+ PctOfTokens = if ($totalTokens -gt 0) { [math]::Round(($mt / $totalTokens) * 100, 1) } else { 0 }
+ }
+ } | Sort-Object TotalTokens -Descending
+ )
+
+ $byAccount = @(
+ $acctTokens.GetEnumerator() | ForEach-Object {
+ $c = if ($costByAcct.ContainsKey($_.Key)) { $costByAcct[$_.Key] } else { 0.0 }
+ $tk = $_.Value.Tokens
+ [PSCustomObject]@{
+ Name = $_.Value.Name
+ ResourceId = $_.Key
+ Tokens = [long]$tk
+ Requests = [long]$_.Value.Requests
+ Cost = [math]::Round($c, 2)
+ CostPer1KTokens = if ($tk -gt 0) { [math]::Round(($c / $tk) * 1000, 4) } else { 0 }
+ }
+ } | Sort-Object Cost -Descending
+ )
+
+ $hasData = $anyAI
+
+ if ($fromHub) {
+ $approxNote = if ($hubApprox) { ' Token volume is approximate where the billing unit could not be parsed.' } else { '' }
+ if ($metricsOk -and $costOk) {
+ $note = "AI spend and billed token volume read from the FinOps Hub export (no live API calls). Cost per request is unavailable on the export path - request counts are not billed line items.$approxNote"
+ }
+ elseif ($costOk) {
+ $note = "AI spend read from the FinOps Hub export, but the export had no token-metered Azure OpenAI usage to price.$approxNote"
+ }
+ else {
+ $note = "AI footprint detected; the FinOps Hub export had no Cognitive Services spend in the period.$approxNote"
+ }
+ }
+ elseif ($metricsOk -and $costOk) {
+ $note = 'Token volume is month-to-date from Azure Monitor; cost is month-to-date amortized.'
+ }
+ elseif ($metricsOk) {
+ $note = 'Token volume available, but AI cost could not be read (Cost Management Reader needed to compute cost per token).'
+ }
+ elseif ($openAiAccounts.Count -gt 0) {
+ $note = 'AI accounts detected but no token usage in the current month (or metrics access unavailable).'
+ }
+ else {
+ $note = 'AI footprint detected (ML/Search/GPU); no token-metered Azure OpenAI usage to price.'
+ }
+
+ return [PSCustomObject]@{
+ HasData = $hasData
+ AIFootprint = $footprint
+ TotalPromptTokens = [long]$totalPrompt
+ TotalGeneratedTokens = [long]$totalGen
+ TotalTokens = [long]$totalTokens
+ TotalRequests = [long]$totalReq
+ TotalAICost = [math]::Round($aiCost, 2)
+ Currency = $currency
+ CostPer1KTokens = $costPer1kTokens
+ CostPerRequest = $costPerRequest
+ ByModel = $byModel
+ ByAccount = $byAccount
+ Period = 'MonthToDate'
+ Source = if ($fromHub) { 'FinOpsHub' } else { 'API' }
+ ScannedSubs = $Subscriptions.Count
+ Note = $note
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1
new file mode 100644
index 000000000..b8149695c
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1
@@ -0,0 +1,89 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-AHBVMSAVINGSRATIO.PS1
+# AZURE FINOPS MULTITOOL - Per-SKU Azure Hybrid Benefit Savings Ratio
+###########################################################################
+# Purpose: Compute the real Azure Hybrid Benefit savings for a VM size by
+# comparing the Windows and Linux pay-as-you-go rates from the
+# Azure Retail Prices API. AHB removes the Windows Server license
+# premium, so the post-AHB cost is the Linux-equivalent rate.
+#
+# Description:
+# 1. Queries the public Retail Prices API for the SKU + region
+# 2. Picks the Windows and Linux Consumption meters (skips Spot / Low Priority)
+# 3. Get-AhbVmRates returns Windows/Linux rates, the remaining-cost ratio,
+# and the hourly Windows license premium (Windows rate - Linux rate)
+# 4. Get-AhbVmSavingsRatio returns just the ratio (falls back to 0.6 / ~40% off)
+# 5. Caches per SKU+region
+#
+# The values are size-specific: small / burstable SKUs carry a different Windows
+# license premium than a flat 40% assumption.
+#
+# -- Parameters ----------------------------------------------------
+# VmSize ARM SKU name, e.g. Standard_D4as_v6
+# Region ARM region name, e.g. eastus
+#
+# Prerequisites:
+# - Outbound HTTPS to prices.azure.com (no auth required)
+#
+# Usage: Get-AhbVmRates -VmSize 'Standard_D4as_v6' -Region 'eastus'
+###########################################################################
+
+function Get-AhbVmRates {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$VmSize,
+ [Parameter(Mandatory)][string]$Region
+ )
+
+ if (-not $script:AhbRateCache) { $script:AhbRateCache = @{} }
+ if ([string]::IsNullOrWhiteSpace($VmSize) -or [string]::IsNullOrWhiteSpace($Region)) { return $null }
+
+ $key = "$VmSize|$Region".ToLowerInvariant()
+ if ($script:AhbRateCache.ContainsKey($key)) { return $script:AhbRateCache[$key] }
+
+ $result = $null
+ try {
+ $filter = "armRegionName eq '$Region' and armSkuName eq '$VmSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'"
+ $url = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))"
+ $resp = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 20
+ $items = @($resp.Items) | Where-Object {
+ $_.unitPrice -gt 0 -and
+ $_.skuName -notmatch 'Spot|Low Priority' -and
+ $_.meterName -notmatch 'Spot|Low Priority'
+ }
+ $win = $items | Where-Object { $_.productName -match 'Windows' } | Select-Object -First 1
+ $lin = $items | Where-Object { $_.productName -notmatch 'Windows' } | Select-Object -First 1
+ if ($win -and $lin -and [double]$win.unitPrice -gt 0) {
+ $w = [double]$win.unitPrice
+ $l = [double]$lin.unitPrice
+ if ($l -gt 0 -and $l -lt $w) {
+ $result = [PSCustomObject]@{
+ WindowsRate = $w
+ LinuxRate = $l
+ Ratio = [math]::Round($l / $w, 4)
+ HourlyPremium = [math]::Round($w - $l, 5)
+ }
+ }
+ }
+ } catch {
+ Write-Warning " AHB rate lookup failed for ${VmSize}/${Region}: $($_.Exception.Message)"
+ }
+
+ $script:AhbRateCache[$key] = $result
+ return $result
+}
+
+function Get-AhbVmSavingsRatio {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$VmSize,
+ [Parameter(Mandatory)][string]$Region
+ )
+
+ $rates = Get-AhbVmRates -VmSize $VmSize -Region $Region
+ if ($rates) { return $rates.Ratio }
+ return 0.6 # ~40% off fallback when live rates are unavailable
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1
new file mode 100644
index 000000000..df4915a94
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1
@@ -0,0 +1,159 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-ANOMALYALERTS.PS1
+# AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts
+###########################################################################
+# Purpose: Query Azure Cost Management for triggered alerts (anomaly,
+# budget, forecast) and configured anomaly alert rules
+# (InsightAlert scheduled actions) across all subscriptions.
+###########################################################################
+
+function Get-AnomalyAlerts {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Querying anomaly & budget alerts ($subCount subs)..." -ForegroundColor Cyan
+
+ $triggeredAlerts = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $configuredRules = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying anomaly alerts ($i/$subCount subs)..."
+ }
+ }
+
+ # -- Triggered Cost Management alerts --
+ try {
+ $alertPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/alerts?api-version=2023-09-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $alertPath -Method GET
+
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ if ($data.value) {
+ foreach ($alert in $data.value) {
+ $p = $alert.properties
+ $def = if ($p.definition) { $p.definition } else { @{} }
+ $det = if ($p.details) { $p.details } else { @{} }
+
+ $alertType = if ($def.type) { $def.type } else { 'Unknown' }
+ $category = if ($def.category) { $def.category } else { '' }
+ $criteria = if ($def.criteria) { $def.criteria } else { '' }
+ $status = if ($p.status) { $p.status } else { 'Unknown' }
+
+ $amount = if ($det.amount) { [math]::Round([double]$det.amount, 2) } else { 0 }
+ $currentSpend = if ($det.currentSpend) { [math]::Round([double]$det.currentSpend, 2) } else { 0 }
+ $unit = if ($det.unit) { $det.unit } else { 'USD' }
+
+ # Cost Management names alerts with a GUID. Derive a human
+ # label: prefer the alert description, then the related
+ # budget/scope leaf (costEntityId), then a Category/Type
+ # composite, and only fall back to the GUID as a last resort.
+ $description = if ($p.description) { [string]$p.description } else { '' }
+ $costEntityId = if ($p.costEntityId) { [string]$p.costEntityId } else { '' }
+ $relatedTo = if ($costEntityId) { ($costEntityId -split '/')[-1] } else { '' }
+ $alertLabel =
+ if ($description) { $description }
+ elseif ($relatedTo) { "$relatedTo ($alertType)" }
+ else {
+ $composite = (@($category, $alertType) | Where-Object { $_ -and $_ -ne 'Unknown' }) -join ' '
+ if ($composite) { $composite } else { $alert.name }
+ }
+
+ $contacts = @()
+ if ($det.contactEmails) { $contacts += @($det.contactEmails) }
+ if ($det.contactRoles) { $contacts += @($det.contactRoles) }
+
+ $createdAt = ''
+ if ($p.creationTime) {
+ try { $createdAt = ([datetime]$p.creationTime).ToString('yyyy-MM-dd') } catch { $createdAt = $p.creationTime }
+ }
+
+ [void]$triggeredAlerts.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ AlertName = $alert.name
+ AlertLabel = $alertLabel
+ RelatedTo = $relatedTo
+ Description = $description
+ AlertType = $alertType
+ Category = $category
+ Criteria = $criteria
+ Status = $status
+ Amount = $amount
+ CurrentSpend = $currentSpend
+ Unit = $unit
+ Contacts = (($contacts | Select-Object -Unique) -join ', ')
+ CreatedAt = $createdAt
+ })
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Alert query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+
+ # -- Configured anomaly alert rules (InsightAlert scheduled actions) --
+ try {
+ $saPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/scheduledActions?api-version=2023-03-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $saPath -Method GET
+
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ if ($data.value) {
+ $insightActions = @($data.value | Where-Object { $_.kind -eq 'InsightAlert' })
+ foreach ($sa in $insightActions) {
+ $p = if ($sa.properties) { $sa.properties } else { @{} }
+
+ $toEmails = ''
+ if ($p.notification -and $p.notification.to) {
+ $toEmails = ($p.notification.to -join ', ')
+ }
+
+ $nextRun = ''
+ if ($p.nextRunTime) {
+ try { $nextRun = ([datetime]$p.nextRunTime).ToString('yyyy-MM-dd') } catch { $nextRun = $p.nextRunTime }
+ }
+
+ [void]$configuredRules.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ RuleName = $sa.name
+ DisplayName = if ($p.displayName) { $p.displayName } else { $sa.name }
+ Status = if ($p.status) { $p.status } else { 'Unknown' }
+ Scope = if ($p.scope) { $p.scope } else { "/subscriptions/$($sub.Id)" }
+ ToEmails = $toEmails
+ NextRunTime = $nextRun
+ })
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Scheduled action query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ $anomalyCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Anomaly' }).Count
+ $activeCount = @($triggeredAlerts | Where-Object { $_.Status -eq 'Active' }).Count
+ $budgetCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Budget' }).Count
+
+ return [PSCustomObject]@{
+ TriggeredAlerts = @($triggeredAlerts)
+ ConfiguredRules = @($configuredRules)
+ TotalAlerts = $triggeredAlerts.Count
+ AnomalyAlertCount = $anomalyCount
+ ActiveAlertCount = $activeCount
+ BudgetAlertCount = $budgetCount
+ ConfiguredRuleCount = $configuredRules.Count
+ HasData = ($triggeredAlerts.Count -gt 0 -or $configuredRules.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1
new file mode 100644
index 000000000..6282f274f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1
@@ -0,0 +1,130 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-BILLINGACCOUNT.PS1
+# DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY
+###########################################################################
+# Purpose: Read-only lookup of billing accounts you can see, their agreement
+# type, whether they support cost allocation rules, and whether you
+# can reach the rules endpoint (Cost Management Contributor signal).
+# Date: Created for FinOps Multitool shared-cost allocation
+#
+# Description:
+# Lists Microsoft.Billing billing accounts visible to the signed-in identity
+# and, for each, reports:
+# 1. Billing account id (the path segment set_cost_allocation_rule needs)
+# 2. Agreement type (EA / MCA / MPA / MOSA)
+# 3. Eligibility - cost allocation rules require EA or MCA
+# 4. Optional access probe - GETs the costAllocationRules endpoint so you
+# learn before an apply whether you have read access there (a 403 means
+# you lack Cost Management Contributor at that scope)
+#
+# This tool is READ-ONLY. It never writes or changes anything in Azure.
+#
+# ── Parameters ──────────────────────────────────────────────────
+# BillingAccountId Optional filter - return only this billing account
+# ProbeAccess When true (default), probe the rules endpoint per account
+#
+# Prerequisites:
+# - Az.Accounts connected (Connect-AzAccount). Billing Reader is enough to list.
+#
+# Usage: Get-BillingAccount
+###########################################################################
+
+function Get-BillingAccount {
+ param(
+ [Parameter()]
+ [string]$BillingAccountId,
+
+ [Parameter()]
+ [bool]$ProbeAccess = $true
+ )
+
+ $billingApi = '2020-05-01'
+ $ruleApi = '2025-03-01'
+
+ Write-Host ' Listing billing accounts...' -ForegroundColor Cyan
+ $listPath = "/providers/Microsoft.Billing/billingAccounts?api-version=$billingApi"
+ $resp = Invoke-AzRestMethodWithRetry -Path $listPath -Method 'GET'
+
+ $status = if ($resp) { [int]$resp.StatusCode } else { 0 }
+ if ($status -ne 200) {
+ $msg = "Could not list billing accounts (HTTP $status)."
+ if ($resp -and $resp.Content) {
+ try { $e = $resp.Content | ConvertFrom-Json -ErrorAction Stop; if ($e.error.message) { $msg += " $($e.error.message)" } } catch { }
+ }
+ return [PSCustomObject]@{
+ HasData = $false
+ Accounts = @()
+ Note = "$msg Ensure you are signed in (Connect-AzAccount) and have at least Billing Reader."
+ }
+ }
+
+ $payload = $null
+ try { $payload = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { }
+ $raw = @()
+ if ($payload -and $payload.value) { $raw = @($payload.value) }
+
+ if ($BillingAccountId) {
+ $raw = @($raw | Where-Object { $_.name -eq $BillingAccountId })
+ }
+
+ if ($raw.Count -eq 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Accounts = @()
+ Note = if ($BillingAccountId) { "No billing account named '$BillingAccountId' is visible to this identity." } else { 'No billing accounts are visible to this identity.' }
+ }
+ }
+
+ $eligibleTypes = @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')
+ $accounts = @()
+
+ foreach ($ba in $raw) {
+ $name = [string]$ba.name
+ $agreement = [string]$ba.properties.agreementType
+ $eligible = $eligibleTypes -contains $agreement
+
+ $eligNote = switch ($agreement) {
+ 'EnterpriseAgreement' { 'Supported (EA enrollment).' }
+ 'MicrosoftCustomerAgreement' { 'Supported (MCA).' }
+ 'MicrosoftPartnerAgreement' { 'Not supported - CSP/partner agreements cannot use cost allocation rules.' }
+ 'MicrosoftOnlineServicesProgram' { 'Not supported - pay-as-you-go / MOSA cannot use cost allocation rules.' }
+ default { "Unknown agreement type '$agreement' - verify cost allocation support." }
+ }
+
+ $access = $null
+ if ($ProbeAccess -and $eligible) {
+ $probePath = "/providers/Microsoft.Billing/billingAccounts/$name/providers/Microsoft.CostManagement/costAllocationRules?api-version=$ruleApi"
+ $pr = Invoke-AzRestMethodWithRetry -Path $probePath -Method 'GET'
+ $ps = if ($pr) { [int]$pr.StatusCode } else { 0 }
+ $access = switch ($ps) {
+ 200 { 'Ok - you can read cost allocation rules here (write needs Cost Management Contributor).' }
+ 403 { 'Forbidden - you lack access at this scope. Cost Management Contributor is required to write rules.' }
+ 404 { 'Not found - the cost allocation provider is not enabled or the account does not expose it.' }
+ default { "HTTP $ps when reading the rules endpoint." }
+ }
+ }
+
+ $accounts += [PSCustomObject]@{
+ Id = $name
+ DisplayName = [string]$ba.properties.displayName
+ AgreementType = $agreement
+ AccountStatus = [string]$ba.properties.accountStatus
+ Eligible = $eligible
+ EligibilityNote = $eligNote
+ RulesAccess = $access
+ }
+ }
+
+ $hasNext = if ($payload.PSObject.Properties['nextLink'] -and $payload.nextLink) { $true } else { $false }
+ $note = 'Use Id as billingAccountId in set_cost_allocation_rule. Only Eligible=true accounts (EA/MCA) support cost allocation rules.'
+ if ($hasNext) { $note += ' More accounts exist beyond this page (nextLink present).' }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Accounts = @($accounts | Sort-Object -Property @{ Expression = 'Eligible'; Descending = $true }, 'DisplayName')
+ Note = $note
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1
new file mode 100644
index 000000000..a7bc2d51a
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1
@@ -0,0 +1,204 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-BILLINGSTRUCTURE.PS1
+# AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation
+###########################################################################
+# Purpose: Retrieve billing account structure (profiles, invoice sections)
+# and any configured cost allocation rules. Requires Billing Reader
+# on the billing account for full data; falls back gracefully.
+###########################################################################
+
+function Get-BillingStructure {
+ [CmdletBinding()]
+ param(
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Querying billing structure..." -ForegroundColor Cyan
+
+ $billingAccounts = @()
+ $billingProfiles = @()
+ $invoiceSections = @()
+ $costAllocationRules = @()
+
+ # -- Resolve billing account IDs linked to scanned subscriptions -----
+ # Query ALL scanned subscriptions (not just 5) to build a complete set
+ # of billing accounts that belong to this tenant/scan scope.
+ $tenantBillingAccountNames = @{}
+ if ($Subscriptions) {
+ foreach ($sub in $Subscriptions) {
+ try {
+ $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01"
+ $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET
+ if ($biResp.StatusCode -eq 200) {
+ $biResult = ($biResp.Content | ConvertFrom-Json)
+ $baId = $biResult.properties.billingAccountId
+ if ($baId) {
+ # Normalize: extract the account name portion after /billingAccounts/
+ $baName = ($baId -replace '(?i).*/billingAccounts/', '').Trim('/')
+ if ($baName) { $tenantBillingAccountNames[$baName] = $true }
+ }
+ }
+ } catch { }
+ }
+ Write-Host " Resolved $($tenantBillingAccountNames.Count) billing account(s) linked to scanned subscriptions." -ForegroundColor Cyan
+ }
+
+ # -- Step 1: Get Billing Accounts -----------------------------------
+ try {
+ $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01"
+ $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET
+ if ($baResp.StatusCode -eq 200) {
+ $baResult = ($baResp.Content | ConvertFrom-Json)
+ if ($baResult.value) {
+ foreach ($ba in $baResult.value) {
+ # Filter to billing accounts associated with scanned subscriptions
+ if ($tenantBillingAccountNames.Count -gt 0 -and -not $tenantBillingAccountNames.ContainsKey($ba.name)) {
+ continue
+ }
+ # If no billing account names resolved at all, skip rather than showing everything
+ if ($tenantBillingAccountNames.Count -eq 0) {
+ Write-Warning " Could not resolve any billing account IDs from subscriptions — skipping billing account: $($ba.properties.displayName)"
+ continue
+ }
+ $props = $ba.properties
+ $billingAccounts += [PSCustomObject]@{
+ AccountId = $ba.name
+ DisplayName = $props.displayName
+ AgreementType = $props.agreementType
+ AccountType = $props.accountType
+ AccountStatus = $props.accountStatus
+ FullId = $ba.id
+ }
+ }
+ }
+ } else {
+ Write-Warning " Billing accounts returned HTTP $($baResp.StatusCode)"
+ }
+ } catch {
+ Write-Warning " Billing accounts query failed: $($_.Exception.Message)"
+ }
+
+ # -- Step 2: Get Billing Profiles (MCA only) ------------------------
+ foreach ($ba in $billingAccounts) {
+ if ($ba.AgreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) {
+ continue
+ }
+ try {
+ $bpPath = "$($ba.FullId)/billingProfiles?api-version=2024-04-01"
+ $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET
+ if ($bpResp.StatusCode -eq 200) {
+ $bpResult = ($bpResp.Content | ConvertFrom-Json)
+ if ($bpResult.value) {
+ foreach ($bp in $bpResult.value) {
+ $bpProps = $bp.properties
+ $billingProfiles += [PSCustomObject]@{
+ ProfileId = $bp.name
+ DisplayName = $bpProps.displayName
+ BillingAccount = $ba.DisplayName
+ Currency = $bpProps.currency
+ InvoiceDay = $bpProps.invoiceDay
+ Status = $bpProps.status
+ FullId = $bp.id
+ }
+
+ # -- Step 3: Invoice Sections per Profile -------
+ try {
+ $isPath = "$($bp.id)/invoiceSections?api-version=2024-04-01"
+ $isResp = Invoke-AzRestMethodWithRetry -Path $isPath -Method GET
+ if ($isResp.StatusCode -eq 200) {
+ $isResult = ($isResp.Content | ConvertFrom-Json)
+ if ($isResult.value) {
+ foreach ($section in $isResult.value) {
+ $sProps = $section.properties
+ $invoiceSections += [PSCustomObject]@{
+ SectionId = $section.name
+ DisplayName = $sProps.displayName
+ BillingProfile = $bpProps.displayName
+ BillingAccount = $ba.DisplayName
+ State = $sProps.state
+ SystemId = $sProps.systemId
+ FullId = $section.id
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Invoice sections query failed for profile $($bpProps.displayName): $($_.Exception.Message)"
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Billing profiles query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 4: EA Departments & Enrollment Accounts (EA only) ---------
+ $eaDepartments = @()
+ foreach ($ba in $billingAccounts) {
+ if ($ba.AgreementType -ne 'EnterpriseAgreement') { continue }
+ try {
+ $deptPath = "$($ba.FullId)/departments?api-version=2024-04-01"
+ $deptResp = Invoke-AzRestMethodWithRetry -Path $deptPath -Method GET
+ if ($deptResp.StatusCode -eq 200) {
+ $deptResult = ($deptResp.Content | ConvertFrom-Json)
+ if ($deptResult.value) {
+ foreach ($dept in $deptResult.value) {
+ $dProps = $dept.properties
+ $eaDepartments += [PSCustomObject]@{
+ DepartmentId = $dept.name
+ DisplayName = $dProps.displayName
+ BillingAccount = $ba.DisplayName
+ CostCenter = $dProps.costCenter
+ Status = $dProps.status
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " EA departments query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 5: Cost Allocation Rules ----------------------------------
+ foreach ($ba in $billingAccounts) {
+ try {
+ $carPath = "$($ba.FullId)/providers/Microsoft.CostManagement/costAllocationRules?api-version=2023-11-01"
+ $carResp = Invoke-AzRestMethodWithRetry -Path $carPath -Method GET
+ if ($carResp.StatusCode -eq 200) {
+ $carResult = ($carResp.Content | ConvertFrom-Json)
+ if ($carResult.value) {
+ foreach ($rule in $carResult.value) {
+ $rProps = $rule.properties
+ $costAllocationRules += [PSCustomObject]@{
+ RuleName = $rProps.name
+ Description = $rProps.description
+ Status = $rProps.status
+ BillingAccount = $ba.DisplayName
+ SourceCount = if ($rProps.details.sourceResources) { $rProps.details.sourceResources.Count } else { 0 }
+ TargetCount = if ($rProps.details.targetResources) { $rProps.details.targetResources.Count } else { 0 }
+ CreatedDate = $rProps.createdDate
+ UpdatedDate = $rProps.updatedDate
+ }
+ }
+ }
+ } elseif ($carResp.StatusCode -ne 404) {
+ Write-Warning " Cost allocation rules returned HTTP $($carResp.StatusCode)"
+ }
+ } catch {
+ Write-Warning " Cost allocation rules query failed: $($_.Exception.Message)"
+ }
+ }
+
+ return [PSCustomObject]@{
+ BillingAccounts = $billingAccounts
+ BillingProfiles = $billingProfiles
+ InvoiceSections = $invoiceSections
+ EADepartments = $eaDepartments
+ CostAllocationRules = $costAllocationRules
+ HasBillingAccess = ($billingAccounts.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1
new file mode 100644
index 000000000..95f444743
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1
@@ -0,0 +1,367 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-BUDGETSTATUS.PS1
+# AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison
+###########################################################################
+# Purpose: Query Azure Budgets (Consumption API) for each subscription to
+# show configured budget amount vs current spend. Highlights
+# subscriptions at risk of overrun.
+###########################################################################
+
+function Get-BudgetStatus {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ $CostData # Existing cost data keyed by subscription ID
+ )
+
+ # Guard: extract hashtable if pipeline pollution wrapped it in an array
+ if ($CostData -and $CostData -isnot [hashtable]) {
+ $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1]
+ }
+ if (-not $CostData) { $CostData = @{} }
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Querying budget status ($subCount subs)..." -ForegroundColor Cyan
+
+ $budgets = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $subsWithBudget = 0
+ $subsWithoutBudget = 0
+ $sampled = $false
+
+ # -- For large tenants, sample first to see if budgets exist --------
+ $subsToQuery = $Subscriptions
+ if ($subCount -gt 50) {
+ $sampleSize = [math]::Min(10, $subCount)
+ Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow
+ $sampleSubs = $Subscriptions | Select-Object -First $sampleSize
+ $sampleHits = 0
+ foreach ($sub in $sampleSubs) {
+ try {
+ $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $sampleBudgets = ($resp.Content | ConvertFrom-Json).value
+ if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ }
+ }
+ }
+ catch { }
+ }
+
+ if ($sampleHits -eq 0) {
+ Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow
+ $sampled = $true
+ $subsWithoutBudget = $subCount
+ $subsToQuery = @() # Skip the main loop
+ }
+ else {
+ Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan
+ }
+ }
+
+ $i = 0
+ foreach ($sub in $subsToQuery) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying budgets ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
+
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value -and $data.value.Count -gt 0) {
+ $subsWithBudget++
+ foreach ($budget in $data.value) {
+ $bp = $budget.properties
+ $amount = [math]::Round([double]$bp.amount, 2)
+ $timeGrain = $bp.timeGrain
+ $category = $bp.category
+
+ # Current spend from our existing cost data
+ $actualSpend = 0
+ $forecast = 0
+ $spendKnown = ($CostData -and $CostData.ContainsKey($sub.Id))
+ if ($spendKnown) {
+ $actualSpend = [math]::Round($CostData[$sub.Id].Actual, 2)
+ $forecast = [math]::Round($CostData[$sub.Id].Forecast, 2)
+ }
+
+ # Calculate % used
+ $pctUsed = if ($amount -gt 0) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { 0 }
+ $pctForecast = if ($amount -gt 0) { [math]::Round(($forecast / $amount) * 100, 1) } else { 0 }
+
+ # Risk level
+ # 'Unknown' means we could not read current spend for this
+ # subscription, so we must NOT claim the budget is On Track.
+ # 'Over Budget' means actual spend has already exceeded the budget.
+ # 'Forecast Over' means actual is still within budget but the
+ # month-end forecast is projected to exceed it (early warning).
+ $risk = if (-not $spendKnown) { 'Unknown' }
+ elseif ($pctUsed -gt 100) { 'Over Budget' }
+ elseif ($pctForecast -gt 100) { 'Forecast Over' }
+ elseif ($pctForecast -gt 90) { 'At Risk' }
+ elseif ($pctForecast -gt 75) { 'Watch' }
+ else { 'On Track' }
+
+ # Notification thresholds and contacts
+ $thresholds = @()
+ $contactEmails = @()
+ $contactRoles = @()
+ if ($bp.notifications) {
+ foreach ($notif in $bp.notifications.PSObject.Properties) {
+ $np = $notif.Value
+ $thresholds += "$($np.threshold)% ($($np.operator))"
+ if ($np.contactEmails) { $contactEmails += @($np.contactEmails) }
+ if ($np.contactRoles) { $contactRoles += @($np.contactRoles) }
+ }
+ }
+
+ # Extract tag filters from budget filter property
+ $tagFilters = @()
+ if ($bp.filter -and $bp.filter.tags) {
+ foreach ($tagProp in $bp.filter.tags.PSObject.Properties) {
+ $tagKey = $tagProp.Name
+ $tagVals = @()
+ if ($tagProp.Value -and $tagProp.Value.values) {
+ $tagVals = @($tagProp.Value.values)
+ }
+ $tagFilters += "$tagKey=$($tagVals -join '|')"
+ }
+ }
+ if ($bp.filter -and $bp.filter.dimensions) {
+ foreach ($dimProp in $bp.filter.dimensions.PSObject.Properties) {
+ if ($dimProp.Name -match '^Tag') {
+ $dimName = $dimProp.Name -replace '^Tag', ''
+ $dimVals = if ($dimProp.Value.values) { @($dimProp.Value.values) } else { @() }
+ $tagFilters += "$dimName=$($dimVals -join '|')"
+ }
+ }
+ }
+ $tagFilterStr = $tagFilters -join '; '
+
+ [void]$budgets.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ BudgetName = $budget.name
+ Amount = $amount
+ TimeGrain = $timeGrain
+ Category = $category
+ ActualSpend = $actualSpend
+ Forecast = $forecast
+ PctUsed = $pctUsed
+ PctForecast = $pctForecast
+ Risk = $risk
+ Thresholds = ($thresholds -join ', ')
+ ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ')
+ ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ')
+ TagFilter = $tagFilterStr
+ Currency = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id].Currency } else { 'USD' }
+ })
+ }
+ }
+ else {
+ $subsWithoutBudget++
+ }
+ }
+ else {
+ $subsWithoutBudget++
+ }
+ }
+ catch {
+ Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)"
+ $subsWithoutBudget++
+ }
+ }
+
+ # Count risk levels
+ # OverBudgetCount = actual spend already exceeded budget (urgent / red).
+ # AtRiskCount = forecast-driven warnings (projected over, or trending high).
+ $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count
+ $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count
+
+ return [PSCustomObject]@{
+ Budgets = @($budgets)
+ TotalBudgets = $budgets.Count
+ SubsWithBudget = $subsWithBudget
+ SubsWithoutBudget = $subsWithoutBudget
+ OverBudgetCount = $overBudget
+ AtRiskCount = $atRisk
+ HasData = ($budgets.Count -gt 0)
+ Sampled = $sampled
+ BudgetCoverage = if ($Subscriptions.Count -gt 0) {
+ [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1)
+ }
+ else { 0 }
+ }
+}
+
+function Get-BudgetHistory {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Budgets,
+
+ [Parameter()]
+ [int]$MonthsBack = 6,
+
+ # Optional Cost Trend result (from Get-CostTrend). When supplied, its
+ # already-fetched per-subscription monthly spend is reused instead of
+ # re-querying the (throttle-prone) Cost Management Query API.
+ [Parameter()]
+ [object]$CostTrend
+ )
+
+ if (-not $Budgets -or $Budgets.Count -eq 0) { return @() }
+
+ $history = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Build a sub -> { 'yyyy-MM' = cost } lookup from Cost Trend so Budget
+ # History can avoid hitting Cost Management again. Cost Trend already
+ # fetched the last 6 months of monthly spend per subscription — exactly the
+ # data Budget History needs — so reusing it eliminates the redundant,
+ # 429-prone per-sub queries that were leaving history empty under throttle.
+ $trendBySub = @{}
+ if ($CostTrend -and $CostTrend.BySubscription) {
+ foreach ($k in @($CostTrend.BySubscription.Keys)) {
+ $lookup = @{}
+ foreach ($m in $CostTrend.BySubscription[$k]) {
+ if ($m.MonthDate -is [datetime]) {
+ $lookup[$m.MonthDate.ToString('yyyy-MM')] = [math]::Round([double]$m.Cost, 2)
+ }
+ }
+ if ($lookup.Count -gt 0) { $trendBySub[$k] = $lookup }
+ }
+ }
+
+ # Group budgets by subscription to minimize API calls
+ $bySubId = $Budgets | Group-Object SubscriptionId
+
+ foreach ($subGroup in $bySubId) {
+ $subId = $subGroup.Name
+ $subName = $subGroup.Group[0].Subscription
+
+ # Prefer reusing Cost Trend data (zero extra API calls). Fall back to a
+ # live per-sub Cost Management query only when trend data is missing.
+ $monthlyCosts = $null
+ if ($trendBySub.ContainsKey($subId)) {
+ $monthlyCosts = $trendBySub[$subId]
+ }
+ else {
+ # Query monthly costs for this sub over the last N months
+ $startDate = (Get-Date).AddMonths(-$MonthsBack).ToString('yyyy-MM-01')
+ $endDate = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') # Last day of previous month
+
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'Custom'
+ timePeriod = @{ from = $startDate; to = $endDate }
+ dataset = @{
+ granularity = 'Monthly'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body
+ if ($resp.StatusCode -ne 200) { continue }
+
+ $result = ($resp.Content | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows) { continue }
+
+ # Parse columns
+ $cols = $result.properties.columns
+ $costIdx = -1; $dateIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'pretaxcost') { $costIdx = $i }
+ elseif ($n -match 'billingmonth|usagedate') { $dateIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($dateIdx -eq -1) { $dateIdx = 1 }
+ if ($currIdx -eq -1) { $currIdx = 2 }
+
+ # Build month → cost lookup
+ $monthlyCosts = @{}
+ foreach ($row in $result.properties.rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $rawDate = $row[$dateIdx]
+ # Parse date robustly — API may return a [datetime], a YYYYMMDD
+ # integer (e.g. 20260101), or a locale-formatted string
+ # (e.g. "1/1/2026 12:00:00 AM"). Avoid substring slicing, which
+ # mangles non-ISO date formats and zeroes out actual spend.
+ $parsed = $null
+ if ($rawDate -is [datetime]) {
+ $parsed = $rawDate
+ }
+ else {
+ $dateStr = "$rawDate"
+ $digitsOnly = $dateStr -replace '[^0-9]', ''
+ if ($digitsOnly.Length -eq 8 -and $dateStr -notmatch '[/\-:]') {
+ $parsed = [datetime]::ParseExact($digitsOnly, 'yyyyMMdd', $null)
+ }
+ else {
+ try { $parsed = [datetime]::Parse($dateStr) } catch { continue }
+ }
+ }
+ $monthKey = $parsed.ToString('yyyy-MM')
+ $monthlyCosts[$monthKey] = $cost
+ }
+ }
+ catch {
+ Write-Warning " Budget history query failed for $subName : $($_.Exception.Message)"
+ continue
+ }
+ }
+
+ if (-not $monthlyCosts -or $monthlyCosts.Count -eq 0) { continue }
+
+ # Now create history rows per budget per month
+ foreach ($budget in $subGroup.Group) {
+ $budgetAmount = [double]$budget.Amount
+ # For quarterly/annual budgets, pro-rate to monthly equivalent
+ $monthlyAmount = switch ($budget.TimeGrain) {
+ 'Quarterly' { [math]::Round($budgetAmount / 3, 2) }
+ 'Annually' { [math]::Round($budgetAmount / 12, 2) }
+ default { $budgetAmount }
+ }
+
+ for ($m = $MonthsBack; $m -ge 1; $m--) {
+ $monthDate = (Get-Date).AddMonths(-$m)
+ $monthKey = $monthDate.ToString('yyyy-MM')
+ $monthLabel = $monthDate.ToString('MMM yyyy')
+ $actual = if ($monthlyCosts.ContainsKey($monthKey)) { $monthlyCosts[$monthKey] } else { 0 }
+ $pctUsed = if ($monthlyAmount -gt 0) { [math]::Round(($actual / $monthlyAmount) * 100, 1) } else { 0 }
+ $status = if ($pctUsed -gt 100) { 'Over' }
+ elseif ($pctUsed -gt 90) { 'Near Limit' }
+ else { 'Under' }
+
+ [void]$history.Add([PSCustomObject]@{
+ Subscription = $subName
+ BudgetName = $budget.BudgetName
+ Month = $monthLabel
+ MonthSort = $monthKey
+ BudgetAmount = $monthlyAmount
+ ActualSpend = $actual
+ PctUsed = $pctUsed
+ Status = $status
+ Currency = $budget.Currency
+ })
+ }
+ }
+ }
+
+ return @($history | Sort-Object Subscription, BudgetName, MonthSort)
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1
new file mode 100644
index 000000000..f8de8d949
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1
@@ -0,0 +1,224 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-CARBONMETRICS.PS1
+# AZURE FINOPS MULTITOOL - Carbon Emissions (Sustainability)
+###########################################################################
+# Purpose: Query the Azure Carbon Optimization service for greenhouse-gas
+# emissions (kgCO2e) across subscriptions: latest-month total,
+# month-over-month change, and a monthly trend. Supports FinOps
+# sustainability KPIs (carbon footprint, emissions trend).
+###########################################################################
+# Notes:
+# - Endpoint: POST /providers/Microsoft.Carbon/carbonEmissionReports
+# (tenant scope), api-version 2025-04-01. subscriptionList goes in the
+# body (max 100 per call, lowercase ids) so we batch in chunks of 100.
+# - Emissions data lags ~2 months and is only available for months that
+# have been published. We probe recent month windows and walk back
+# until the service returns data, then degrade gracefully if none.
+# - RBAC: Reader (or Carbon Optimization Reader) on each subscription.
+###########################################################################
+
+function Get-CarbonMetrics {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $apiVersion = '2025-04-01'
+ $carbonPath = "/providers/Microsoft.Carbon/carbonEmissionReports?api-version=$apiVersion"
+ $scopeList = @('Scope1', 'Scope2', 'Scope3')
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Querying carbon emissions ($subCount subs)..." -ForegroundColor Cyan
+
+ # All subscription ids, lowercased, batched into <=100 per request.
+ $allIds = @($Subscriptions | ForEach-Object { ([string]$_.Id).ToLower() })
+ $batches = [System.Collections.Generic.List[object]]::new()
+ for ($b = 0; $b -lt $allIds.Count; $b += 100) {
+ $end = [math]::Min($b + 99, $allIds.Count - 1)
+ [void]$batches.Add(@($allIds[$b..$end]))
+ }
+
+ # -- Resolve an available emissions window ----------------------------
+ # Carbon data publishes ~2 months in arrears. Probe candidate "latest"
+ # months (current-1 .. current-4, first of month) using the first
+ # subscription batch; the first window that returns data is reused for
+ # every batch. Returns $null when no recent window has data.
+ $firstOfMonth = Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0
+ $window = $null
+ foreach ($lag in 1..4) {
+ $endMonth = $firstOfMonth.AddMonths(-$lag)
+ $startMonth = $endMonth.AddMonths(-11)
+ $probeBody = @{
+ reportType = 'OverallSummaryReport'
+ subscriptionList = $batches[0]
+ carbonScopeList = $scopeList
+ dateRange = @{
+ start = $startMonth.ToString('yyyy-MM-dd')
+ end = $endMonth.ToString('yyyy-MM-dd')
+ }
+ } | ConvertTo-Json -Depth 6
+
+ try {
+ $probe = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $probeBody
+ }
+ catch {
+ $probe = $null
+ }
+
+ if ($probe -and $probe.StatusCode -eq 200) {
+ $window = @{ Start = $startMonth; End = $endMonth }
+ break
+ }
+
+ # 404/400 here usually means "no data for that window" or the
+ # provider isn't registered/available - try an earlier window.
+ }
+
+ if (-not $window) {
+ Write-Host " No carbon emissions data available (provider unavailable or no published months)." -ForegroundColor DarkGray
+ return [PSCustomObject]@{
+ HasData = $false
+ TotalEmissionsKg = 0
+ PreviousMonthKg = 0
+ ChangeRatio = 0
+ ChangeValueKg = 0
+ LatestMonth = $null
+ MonthlyTrend = @()
+ BySubscription = @()
+ Unit = 'kgCO2e'
+ ScannedSubs = $subCount
+ Note = 'Carbon Optimization returned no data. Requires Reader on subscriptions; emissions publish ~2 months in arrears.'
+ }
+ }
+
+ $startStr = $window.Start.ToString('yyyy-MM-dd')
+ $endStr = $window.End.ToString('yyyy-MM-dd')
+
+ $totalLatest = 0.0
+ $totalPrevious = 0.0
+ $monthlyTotals = @{} # key 'yyyy-MM' -> kgCO2e
+ $bySub = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ $batchNo = 0
+ foreach ($batch in $batches) {
+ $batchNo++
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying carbon emissions (batch $batchNo/$($batches.Count))..."
+ }
+
+ # -- Overall summary (headline latest + previous month) -----------
+ $overallBody = @{
+ reportType = 'OverallSummaryReport'
+ subscriptionList = $batch
+ carbonScopeList = $scopeList
+ dateRange = @{ start = $startStr; end = $endStr }
+ } | ConvertTo-Json -Depth 6
+
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $overallBody
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ foreach ($row in @($data.value)) {
+ $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 }
+ $previous = if ($null -ne $row.previousMonthEmissions) { [double]$row.previousMonthEmissions } else { 0 }
+ $totalLatest += $latest
+ $totalPrevious += $previous
+ }
+ }
+ }
+ catch {
+ Write-Warning " Carbon overall query failed (batch $batchNo): $($_.Exception.Message)"
+ }
+
+ # -- Per-subscription summary (ItemDetails by subscription) -------
+ $itemBody = @{
+ reportType = 'ItemDetailsReport'
+ subscriptionList = $batch
+ carbonScopeList = $scopeList
+ categoryType = 'Subscription'
+ orderBy = 'LatestMonthEmissions'
+ sortDirection = 'Desc'
+ pageSize = 100
+ dateRange = @{ start = $endStr; end = $endStr }
+ } | ConvertTo-Json -Depth 6
+
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $itemBody
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ foreach ($row in @($data.value)) {
+ $subId = if ($row.itemName) { [string]$row.itemName } else { '' }
+ $subObj = $Subscriptions | Where-Object { ([string]$_.Id).ToLower() -eq $subId.ToLower() } | Select-Object -First 1
+ $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 }
+ [void]$bySub.Add([PSCustomObject]@{
+ Subscription = if ($subObj) { $subObj.Name } else { $subId }
+ SubscriptionId = $subId
+ EmissionsKg = [math]::Round($latest, 3)
+ })
+ }
+ }
+ }
+ catch {
+ Write-Warning " Carbon per-subscription query failed (batch $batchNo): $($_.Exception.Message)"
+ }
+
+ # -- Monthly trend (12-month series) ------------------------------
+ $monthlyBody = @{
+ reportType = 'MonthlySummaryReport'
+ subscriptionList = $batch
+ carbonScopeList = $scopeList
+ dateRange = @{ start = $startStr; end = $endStr }
+ } | ConvertTo-Json -Depth 6
+
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $monthlyBody
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ foreach ($row in @($data.value)) {
+ $month = ''
+ if ($row.date) { try { $month = ([datetime]$row.date).ToString('yyyy-MM') } catch { $month = [string]$row.date } }
+ elseif ($row.month) { $month = [string]$row.month }
+ if (-not $month) { continue }
+ $val = if ($null -ne $row.totalCarbonEmission) { [double]$row.totalCarbonEmission }
+ elseif ($null -ne $row.carbonEmission) { [double]$row.carbonEmission }
+ elseif ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions }
+ else { 0 }
+ if (-not $monthlyTotals.ContainsKey($month)) { $monthlyTotals[$month] = 0.0 }
+ $monthlyTotals[$month] += $val
+ }
+ }
+ }
+ catch {
+ Write-Warning " Carbon monthly query failed (batch $batchNo): $($_.Exception.Message)"
+ }
+ }
+
+ $trend = @(
+ $monthlyTotals.Keys | Sort-Object | ForEach-Object {
+ [PSCustomObject]@{ Month = $_; EmissionsKg = [math]::Round($monthlyTotals[$_], 3) }
+ }
+ )
+
+ $changeValue = $totalLatest - $totalPrevious
+ $changeRatio = if ($totalPrevious -ne 0) { [math]::Round(($changeValue / $totalPrevious) * 100, 1) } else { 0 }
+
+ $hasData = ($totalLatest -gt 0) -or ($trend.Count -gt 0) -or ($bySub.Count -gt 0)
+
+ return [PSCustomObject]@{
+ HasData = $hasData
+ TotalEmissionsKg = [math]::Round($totalLatest, 3)
+ PreviousMonthKg = [math]::Round($totalPrevious, 3)
+ ChangeValueKg = [math]::Round($changeValue, 3)
+ ChangeRatio = $changeRatio
+ LatestMonth = $window.End.ToString('yyyy-MM')
+ MonthlyTrend = $trend
+ BySubscription = @($bySub | Sort-Object EmissionsKg -Descending)
+ Unit = 'kgCO2e'
+ ScannedSubs = $subCount
+ Note = if ($hasData) { $null } else { 'No emissions returned for the available window.' }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1
new file mode 100644
index 000000000..35d0d6eef
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1
@@ -0,0 +1,309 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COMMITMENTUTILIZATION.PS1
+# AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization
+###########################################################################
+# Purpose: Query existing reservation and savings plan utilization to show
+# how well current commitments are being used. This answers the
+# CFO question: "Are we wasting what we already bought?"
+###########################################################################
+
+function Get-CommitmentUtilization {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$AgreementType
+ )
+
+ Write-Host " Querying commitment utilization..." -ForegroundColor Cyan
+
+ $reservations = @()
+ $savingsPlans = @()
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # Set to $true if a reservation/savings-plan query is forbidden (401/403)
+ # rather than simply returning no commitments.
+ $accessDenied = $false
+
+ # -- Step 0 (MCA/MPA): Resolve billing profiles for this tenant -----
+ # Under MCA, reservations and savings plans are scoped to the billing
+ # profile, NOT the subscription. Subscription-level Consumption API
+ # calls return empty for MCA agreements.
+ $billingProfileIds = @()
+ if ($AgreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) {
+ Write-Host " MCA/MPA detected — resolving billing profiles..." -ForegroundColor Cyan
+ # Discover billing account IDs linked to scanned subscriptions
+ $tenantBillingAccountIds = @{}
+ foreach ($sub in @($Subscriptions | Select-Object -First 10)) {
+ try {
+ $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01"
+ $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET
+ if ($biResp.StatusCode -eq 200) {
+ $biResult = ($biResp.Content | ConvertFrom-Json)
+ $baId = $biResult.properties.billingAccountId
+ if ($baId) { $tenantBillingAccountIds[$baId] = $true }
+ }
+ } catch { }
+ }
+
+ # Get billing profiles for those accounts
+ try {
+ $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01"
+ $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET
+ if ($baResp.StatusCode -eq 200) {
+ $baResult = ($baResp.Content | ConvertFrom-Json)
+ foreach ($ba in $baResult.value) {
+ if ($tenantBillingAccountIds.Count -gt 0 -and -not $tenantBillingAccountIds.ContainsKey($ba.id)) {
+ # Normalize — try matching on name portion only
+ $baNamePortion = $ba.id -replace '.*/billingAccounts/', ''
+ $matched = $false
+ foreach ($k in $tenantBillingAccountIds.Keys) {
+ $kName = $k -replace '.*/billingAccounts/', ''
+ if ($kName -eq $baNamePortion) { $matched = $true; break }
+ }
+ if (-not $matched) { continue }
+ }
+ if ($ba.properties.agreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { continue }
+ try {
+ $bpPath = "$($ba.id)/billingProfiles?api-version=2024-04-01"
+ $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET
+ if ($bpResp.StatusCode -eq 200) {
+ $bpResult = ($bpResp.Content | ConvertFrom-Json)
+ foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id }
+ }
+ } catch { }
+ }
+ }
+ } catch {
+ Write-Warning " Billing profile resolution failed: $($_.Exception.Message)"
+ }
+ Write-Host " Found $($billingProfileIds.Count) billing profile(s) for MCA commitment queries." -ForegroundColor Cyan
+ }
+
+ # -- Step 1: Get all reservations and their utilization --------------
+ # For MCA: query at billing-profile scope first
+ if ($billingProfileIds.Count -gt 0) {
+ foreach ($bpId in $billingProfileIds) {
+ try {
+ $summaryPath = "$bpId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($item in $data.value) {
+ $p = $item.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $p.reservationOrderId
+ ReservationId = $p.reservationId
+ SkuName = $p.skuName
+ Kind = $p.kind
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1)
+ ReservedHours = $p.reservedHours
+ UsedHours = $p.usedHours
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ }
+ elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Reservation query at billing profile scope failed: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # For EA / fallback: query at subscription scope
+ if ($reservations.Count -eq 0) {
+ foreach ($sub in $Subscriptions | Select-Object -First 10) {
+ try {
+ $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($item in $data.value) {
+ $p = $item.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $p.reservationOrderId
+ ReservationId = $p.reservationId
+ SkuName = $p.skuName
+ Kind = $p.kind
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1)
+ ReservedHours = $p.reservedHours
+ UsedHours = $p.usedHours
+ UsageDate = $p.usageDate
+ }
+ }
+ break # Got data from one sub, don't repeat
+ }
+ }
+ elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Reservation summaries query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Step 2: Try the Reservation Orders API at billing scope --
+ if ($reservations.Count -eq 0) {
+ try {
+ $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $roPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($order in $data.value) {
+ $op = $order.properties
+ if ($op.reservations) {
+ foreach ($ri in $op.reservations) {
+ # Get utilization summary for each reservation
+ try {
+ $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET
+ if ($utilResp.StatusCode -eq 200) {
+ $utilData = ($utilResp.Content | ConvertFrom-Json)
+ if ($utilData.value -and $utilData.value.Count -gt 0) {
+ $latest = $utilData.value | Select-Object -Last 1
+ $up = $latest.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $order.name
+ ReservationId = $ri.id.Split('/')[-1]
+ SkuName = $op.displayProvisioningState
+ Kind = $op.billingScopeId
+ AvgUtilization = [math]::Round([double]$up.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$up.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$up.maxUtilizationPercentage, 1)
+ ReservedHours = $up.reservedHours
+ UsedHours = $up.usedHours
+ UsageDate = $up.usageDate
+ }
+ }
+ }
+ } catch { }
+ }
+ }
+ }
+ }
+ }
+ elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Reservation orders query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries --
+ # For MCA: query at billing-profile scope first
+ if ($billingProfileIds.Count -gt 0 -and $savingsPlans.Count -eq 0) {
+ foreach ($bpId in $billingProfileIds) {
+ try {
+ $spPath = "$bpId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly"
+ $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET
+ if ($spResp.StatusCode -eq 200) {
+ $spData = ($spResp.Content | ConvertFrom-Json)
+ if ($spData.value) {
+ foreach ($item in $spData.value) {
+ $p = $item.properties
+ if ($p.benefitType -eq 'SavingsPlan') {
+ $savingsPlans += [PSCustomObject]@{
+ BenefitId = $p.benefitOrderId
+ BenefitType = $p.benefitType
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ }
+ }
+ elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Savings plan query at billing profile scope failed: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # Fallback: subscription scope (EA, PAYG, etc.)
+ if ($savingsPlans.Count -eq 0) {
+ try {
+ foreach ($sub in $Subscriptions | Select-Object -First 5) {
+ $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly"
+ $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET
+ if ($spResp.StatusCode -eq 200) {
+ $spData = ($spResp.Content | ConvertFrom-Json)
+ if ($spData.value) {
+ foreach ($item in $spData.value) {
+ $p = $item.properties
+ if ($p.benefitType -eq 'SavingsPlan') {
+ $savingsPlans += [PSCustomObject]@{
+ BenefitId = $p.benefitOrderId
+ BenefitType = $p.benefitType
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ if ($savingsPlans.Count -gt 0) { break }
+ }
+ }
+ elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Savings plan utilization query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 4: Calculate summary stats --
+ $riAvgUtil = 0
+ $riCount = $reservations.Count
+ if ($riCount -gt 0) {
+ $riAvgUtil = [math]::Round(($reservations | Measure-Object -Property AvgUtilization -Average).Average, 1)
+ }
+
+ $spAvgUtil = 0
+ $spCount = $savingsPlans.Count
+ if ($spCount -gt 0) {
+ $spAvgUtil = [math]::Round(($savingsPlans | Measure-Object -Property AvgUtilization -Average).Average, 1)
+ }
+
+ $underutilized = @($reservations | Where-Object { $_.AvgUtilization -lt 80 })
+
+ $denied = ($accessDenied -and $riCount -eq 0 -and $spCount -eq 0)
+
+ # Human-readable summary so the zeros below are never mistaken for
+ # "no commitments / all healthy" when the real cause is no access.
+ $note = if ($denied) {
+ 'Access denied reading reservation/savings-plan utilization (needs Cost Management Reader / billing-scope access). The zero counts below reflect missing access, NOT confirmed absence of commitments.'
+ }
+ elseif ($riCount -eq 0 -and $spCount -eq 0) {
+ 'No reservations or savings plans found in scope.'
+ }
+ else {
+ "$riCount reservation(s) avg $riAvgUtil% util; $spCount savings plan(s) avg $spAvgUtil% util."
+ }
+
+ return [PSCustomObject]@{
+ Reservations = $reservations
+ SavingsPlans = $savingsPlans
+ RICount = $riCount
+ SPCount = $spCount
+ RIAvgUtilization = $riAvgUtil
+ SPAvgUtilization = $spAvgUtil
+ UnderutilizedRIs = $underutilized
+ HasData = ($riCount -gt 0 -or $spCount -gt 0)
+ AccessDenied = $denied
+ Note = $note
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1
new file mode 100644
index 000000000..99eea7466
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1
@@ -0,0 +1,158 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-CONTRACTINFO.PS1
+# AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection
+###########################################################################
+# Purpose: Detect the customer's Azure contract type (EA, MCA, PAYGO, CSP)
+# and return billing account details.
+#
+# Contract Types:
+# EnterpriseAgreement Enterprise Agreement (EA)
+# MicrosoftCustomerAgreement Microsoft Customer Agreement (MCA)
+# MicrosoftOnlineServicesProgram Pay-As-You-Go (PAYGO / MOSP)
+# MicrosoftPartnerAgreement CSP / Partner (MPA)
+#
+# Reference: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/view-all-accounts
+###########################################################################
+
+function Get-ContractInfo {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ $inferredAgreement = $null
+ $inferredFriendly = $null
+
+ # -- Step 1: Detect agreement type from subscription quotaId ---------
+ # QuotaId is always scoped to the correct tenant when using passed subs
+ $subsToCheck = if ($Subscriptions) { @($Subscriptions | Select-Object -First 3) } else { @() }
+ if ($subsToCheck.Count -eq 0) {
+ try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { }
+ }
+
+ foreach ($sub in $subsToCheck) {
+ try {
+ $subPath = "/subscriptions/$($sub.Id)?api-version=2022-12-01"
+ $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method GET
+ if ($subResp.StatusCode -eq 200) {
+ $subDetail = ($subResp.Content | ConvertFrom-Json)
+ $quotaId = $subDetail.properties.subscriptionPolicies.quotaId
+
+ $mapped = switch -Regex ($quotaId) {
+ 'EnterpriseAgreement' { @{ Agreement = 'EnterpriseAgreement'; Friendly = 'Enterprise Agreement (EA)' } }
+ 'MCSFree|MSDN|Visual' { @{ Agreement = 'MSDN'; Friendly = 'Visual Studio / MSDN' } }
+ 'PayAsYouGo|PAYG' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } }
+ 'Sponsored' { @{ Agreement = 'Sponsored'; Friendly = 'Azure Sponsored' } }
+ 'CSP' { @{ Agreement = 'MicrosoftPartnerAgreement'; Friendly = 'CSP / Partner Agreement' } }
+ 'Internal' { @{ Agreement = 'Internal'; Friendly = 'Microsoft Internal' } }
+ 'MCA' { @{ Agreement = 'MicrosoftCustomerAgreement'; Friendly = 'Microsoft Customer Agreement (MCA)' } }
+ 'FreeTrial' { @{ Agreement = 'FreeTrial'; Friendly = 'Free Trial' } }
+ 'AAD' { @{ Agreement = 'AAD'; Friendly = 'Azure AD Subscription' } }
+ 'MSAZR' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } }
+ default { @{ Agreement = $quotaId; Friendly = $quotaId } }
+ }
+
+ if ($mapped) {
+ $inferredAgreement = $mapped.Agreement
+ $inferredFriendly = $mapped.Friendly
+ Write-Host " QuotaId detected: $quotaId -> $inferredFriendly" -ForegroundColor Green
+ break
+ }
+ }
+ } catch { }
+ }
+
+ # -- Step 2: Try billing accounts API, filtered by inferred type -----
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET
+ if (-not $response -or -not $response.Content) { throw "Billing accounts API returned no content (HTTP $($response.StatusCode))" }
+ $result = ($response.Content | ConvertFrom-Json)
+
+ if ($result.value -and $result.value.Count -gt 0) {
+ $matchedAccount = $null
+
+ if ($result.value.Count -eq 1) {
+ # Single billing account in scope - unambiguous.
+ $matchedAccount = $result.value[0]
+ }
+ else {
+ # Multiple billing accounts are visible across every tenant the
+ # signed-in identity can reach. Picking by agreement type alone
+ # can surface an account from an unrelated tenant, so confirm the
+ # account actually owns one of the scanned subscriptions before
+ # trusting it. Prefer candidates matching the inferred agreement.
+ $scanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($sc in $subsToCheck) { if ($sc.Id) { [void]$scanIds.Add([string]$sc.Id) } }
+
+ $candidates = if ($inferredAgreement) {
+ @($result.value | Where-Object { $_.properties.agreementType -eq $inferredAgreement })
+ } else { @() }
+ if (-not $candidates -or $candidates.Count -eq 0) { $candidates = @($result.value) }
+
+ foreach ($cand in $candidates) {
+ if ($scanIds.Count -eq 0) { break }
+ try {
+ $bsPath = "/providers/Microsoft.Billing/billingAccounts/$($cand.name)/billingSubscriptions?api-version=2024-04-01"
+ $bsResp = Invoke-AzRestMethodWithRetry -Path $bsPath -Method GET
+ if ($bsResp -and $bsResp.StatusCode -eq 200 -and $bsResp.Content) {
+ $bsData = ($bsResp.Content | ConvertFrom-Json)
+ $owns = $false
+ foreach ($bs in @($bsData.value)) {
+ $bsSubId = if ($bs.properties.subscriptionId) { [string]$bs.properties.subscriptionId } else { [string]$bs.name }
+ if ($scanIds.Contains($bsSubId)) { $owns = $true; break }
+ }
+ if ($owns) { $matchedAccount = $cand; break }
+ }
+ } catch { }
+ }
+ # No account could be confirmed to own the scanned subscription -
+ # fall through to the subscription-accurate quotaId inference.
+ }
+
+ if (-not $matchedAccount) { throw "No billing account confirmed for the scanned subscription" }
+
+ $props = $matchedAccount.properties
+ $friendlyType = switch ($props.agreementType) {
+ 'EnterpriseAgreement' { 'Enterprise Agreement (EA)' }
+ 'MicrosoftCustomerAgreement' { 'Microsoft Customer Agreement (MCA)' }
+ 'MicrosoftOnlineServicesProgram' { 'Pay-As-You-Go (PAYGO)' }
+ 'MicrosoftPartnerAgreement' { 'CSP / Partner Agreement (MPA)' }
+ default { $props.agreementType }
+ }
+
+ return @([PSCustomObject]@{
+ AccountName = $props.displayName
+ AccountId = $matchedAccount.name
+ AgreementType = $props.agreementType
+ FriendlyType = $friendlyType
+ AccountStatus = $props.accountStatus
+ Currency = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' }
+ })
+ }
+ } catch {
+ Write-Warning "Billing account query failed: $($_.Exception.Message)"
+ }
+
+ # -- Step 3: Return quotaId-based inference if billing API failed ----
+ if ($inferredAgreement) {
+ $subName = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Name } else { 'Unknown' }
+ return @([PSCustomObject]@{
+ AccountName = "Inferred from subscription: $subName"
+ AccountId = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Id } else { '' }
+ AgreementType = $inferredAgreement
+ FriendlyType = $inferredFriendly
+ AccountStatus = 'Active'
+ Currency = 'Unknown'
+ })
+ }
+
+ return @([PSCustomObject]@{
+ AccountName = 'Unknown'
+ AgreementType = 'Unknown'
+ FriendlyType = 'Could not detect (assign Billing Reader for accurate detection)'
+ })
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1
new file mode 100644
index 000000000..60504f7ee
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1
@@ -0,0 +1,517 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COSTBYTAG.PS1
+# AZURE FINOPS MULTITOOL - Cost Breakdown by Tag
+###########################################################################
+# Purpose: For each CAF allocation tag (CostCenter, BusinessUnit,
+# etc.), query Cost Management to show how spend distributes
+# across tag values. If no meaningful tags exist, fall back
+# to cost-by-subscription so the user still sees a breakdown.
+#
+# This is the "Understand" pillar - cost allocation and showback.
+###########################################################################
+
+function Get-CostByTag {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [hashtable]$ExistingTags,
+
+ # No -RestrictToSelected here: this scan queries each subscription
+ # individually, so it is always scoped to $Subscriptions.
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ # Tags we want to break cost down by (in priority order — matches CAF allocation tags)
+ $targetTags = @('CostCenter', 'BusinessUnit', 'ApplicationName', 'WorkloadName', 'OpsTeam', 'Criticality', 'DataClassification')
+
+ # Also check variations
+ $variations = @{
+ 'CostCenter' = @('cost-center', 'costcenter', 'cost_center', 'cc')
+ 'BusinessUnit' = @('bu', 'businessunit', 'business-unit', 'department', 'dept')
+ 'ApplicationName' = @('applicationname', 'application', 'app', 'appname', 'app-name')
+ 'WorkloadName' = @('workloadname', 'workload', 'workload-name', 'workload_name')
+ 'OpsTeam' = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner')
+ 'Criticality' = @('criticality', 'sla', 'tier', 'importance')
+ 'DataClassification' = @('dataclassification', 'data-classification', 'data_classification', 'classification')
+ }
+
+ $existingKeys = if ($ExistingTags) { $ExistingTags.Keys | ForEach-Object { $_.ToLower() } } else { @() }
+ $tagsToQuery = @()
+
+ foreach ($tag in $targetTags) {
+ # Check exact match first
+ $match = $existingKeys | Where-Object { $_ -eq $tag.ToLower() } | Select-Object -First 1
+ if ($match) {
+ # Find the properly-cased version from existing tags
+ $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $match } | Select-Object -First 1
+ $tagsToQuery += $properCase
+ continue
+ }
+
+ # Check variations
+ if ($variations.ContainsKey($tag)) {
+ $varMatch = $existingKeys | Where-Object { $_ -in $variations[$tag] } | Select-Object -First 1
+ if ($varMatch) {
+ $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $varMatch } | Select-Object -First 1
+ $tagsToQuery += $properCase
+ }
+ }
+ }
+
+ # Also include any additional existing tags not already in the list
+ # Sorted by resource coverage (descending) so the most-used tags survive any cap
+ if ($ExistingTags) {
+ $alreadyLower = $tagsToQuery | ForEach-Object { $_.ToLower() }
+ $systemPrefixes = @('hidden-', 'ms-resource-', 'aks-managed-', 'kubernetes.io', 'displayname')
+ # Exact-match system/auto-generated tags (Azure Policy, Monitor, Automanage, etc.)
+ $systemExact = @(
+ 'action', 'automanage', 'alertrulecreatedwithalertsrecommendations',
+ 'createdby', 'createddate', 'createdtime', 'createdon',
+ 'environment-type', 'intune-deployed', 'policyassignmentname',
+ 'statuschangedate', 'vmsize', 'offer', 'publisher', 'sku'
+ )
+ $extras = @()
+ foreach ($key in $ExistingTags.Keys) {
+ if ($key.ToLower() -in $alreadyLower) { continue }
+ $skip = $false
+ if ($key.ToLower() -in $systemExact) { $skip = $true }
+ if (-not $skip) {
+ foreach ($prefix in $systemPrefixes) {
+ if ($key.ToLower().StartsWith($prefix)) { $skip = $true; break }
+ }
+ }
+ if (-not $skip) {
+ $coverage = if ($ExistingTags[$key].TotalResources) { $ExistingTags[$key].TotalResources } else { 0 }
+ $extras += [PSCustomObject]@{ Name = $key; Coverage = $coverage }
+ }
+ }
+ $extras = $extras | Sort-Object Coverage -Descending
+ foreach ($e in $extras) { $tagsToQuery += $e.Name }
+ }
+
+ # Skip tags with very low resource coverage (< 3 resources tagged)
+ # These produce mostly "(untagged)" results and waste API calls
+ if ($ExistingTags -and $tagsToQuery.Count -gt 8) {
+ $cafTags = $tagsToQuery | Select-Object -First ([math]::Min($tagsToQuery.Count, 7))
+ $extraTags = $tagsToQuery | Select-Object -Skip 7
+ $filteredExtras = @()
+ foreach ($t in $extraTags) {
+ $tagInfo = if ($ExistingTags.ContainsKey($t)) { $ExistingTags[$t] } else { $null }
+ $count = if ($tagInfo -and $tagInfo.TotalResources) { $tagInfo.TotalResources } else { 0 }
+ if ($count -ge 3) { $filteredExtras += $t }
+ }
+ $skipped = $tagsToQuery.Count - $cafTags.Count - $filteredExtras.Count
+ $tagsToQuery = $cafTags + $filteredExtras
+ if ($skipped -gt 0) {
+ Write-Host " Skipped $skipped low-coverage tags (< 3 resources) to reduce API calls" -ForegroundColor Yellow
+ }
+ }
+
+ $results = @{}
+
+ # Helper: normalize an ARG tags bag (PSCustomObject or hashtable) into a
+ # plain hashtable of tagKey -> tagValue.
+ function ConvertTo-TagHash {
+ param($Tags)
+ $h = @{}
+ if (-not $Tags) { return $h }
+ if ($Tags -is [System.Collections.IDictionary]) {
+ foreach ($k in $Tags.Keys) { $h[[string]$k] = [string]$Tags[$k] }
+ }
+ else {
+ foreach ($p in $Tags.PSObject.Properties) { $h[[string]$p.Name] = [string]$p.Value }
+ }
+ return $h
+ }
+
+ # === Build resource -> tag maps via Azure Resource Graph =============
+ # The reliable, throttle-resistant way to allocate cost by tag is to query
+ # cost grouped by ResourceId (one cheap, universally-supported call per
+ # subscription) and join it client-side against each resource's tags. ARG
+ # supplies that join data without hammering the Cost Management API. We
+ # capture resource-level tags plus resource-group-level tags (used as a
+ # fallback when a resource carries no tag of its own but its RG does).
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $resTagMap = @{} # lowercased resourceId -> @{ tagKey = tagValue }
+ $rgTagMap = @{} # lowercased resourceGroup id -> @{ tagKey = tagValue }
+ try {
+ Write-Host " Building resource -> tag map via Resource Graph..." -ForegroundColor Cyan
+ $resTagQuery = "resources | where isnotempty(tags) | project id, tags"
+ $skip = $null
+ do {
+ $r = Search-AzGraphSafe -Query $resTagQuery -Subscription $subIds -First 1000 -SkipToken $skip
+ if (-not $r -or -not $r.Data) { break }
+ foreach ($row in $r.Data) {
+ if ($row.id) { $resTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags }
+ }
+ $skip = $r.SkipToken
+ } while ($skip)
+
+ $rgTagQuery = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' | where isnotempty(tags) | project id, tags"
+ $skip = $null
+ do {
+ $r = Search-AzGraphSafe -Query $rgTagQuery -Subscription $subIds -First 1000 -SkipToken $skip
+ if (-not $r -or -not $r.Data) { break }
+ foreach ($row in $r.Data) {
+ if ($row.id) { $rgTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags }
+ }
+ $skip = $r.SkipToken
+ } while ($skip)
+
+ Write-Host " Mapped tags for $($resTagMap.Count) resources, $($rgTagMap.Count) resource groups" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning "Resource tag map build failed: $($_.Exception.Message). Cost-by-tag will rely on whatever map data was collected."
+ }
+
+ # Helper: parse a ResourceId-grouped Cost Management response into rows of
+ # @{ ResourceId; Cost; Currency }. Rows with an empty ResourceId represent
+ # non-resource charges (reservations, marketplace, refunds/credits).
+ function Parse-ResourceIdRows {
+ param($ResponseContent)
+ $parsed = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $result = ($ResponseContent | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) {
+ return $parsed
+ }
+ $cols = $result.properties.columns
+ $costIdx = -1; $ridIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ elseif ($n -eq 'resourceid') { $ridIdx = $i }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($ridIdx -eq -1) { $ridIdx = 1 }
+ if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 3) { 2 } else { -1 } }
+
+ foreach ($row in $result.properties.rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $rid = if ($ridIdx -ge 0 -and $ridIdx -lt $row.Count -and $row[$ridIdx]) { [string]$row[$ridIdx] } else { '' }
+ $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ [void]$parsed.Add([PSCustomObject]@{ ResourceId = $rid; Cost = $cost; Currency = $currency })
+ }
+ return $parsed
+ }
+
+ # Helper: parse Cost Management query response using column headers
+ function Parse-CostRows {
+ param($ResponseContent)
+ $parsed = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $result = ($ResponseContent | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) {
+ return $parsed
+ }
+ # Build column index map from response
+ $cols = $result.properties.columns
+ $costIdx = -1; $tagIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ elseif ($n -eq 'tagvalue') { $tagIdx = $i }
+ }
+ # Fallback: if TagValue column not found, pick first String column that isn't TagKey or Currency
+ if ($tagIdx -eq -1) {
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ if ($cols[$i].type -eq 'String' -and $i -ne $currIdx -and $cols[$i].name.ToLower() -ne 'tagkey') { $tagIdx = $i; break }
+ }
+ }
+ # Final positional fallback
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($tagIdx -eq -1) { $tagIdx = if ($cols.Count -ge 4) { 2 } else { 1 } }
+ if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 4) { 3 } else { 2 } }
+
+ foreach ($row in $result.properties.rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $value = if ($row[$tagIdx]) { $row[$tagIdx] } else { '(untagged)' }
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ [void]$parsed.Add([PSCustomObject]@{ TagValue = $value; Cost = $cost; Currency = $currency })
+ }
+ return $parsed
+ }
+
+ # Helper: parse batched TagKey+TagValue response into per-tag results
+ function Parse-BatchedCostRows {
+ param($ResponseContent)
+ $perTag = @{}
+ $result = ($ResponseContent | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) {
+ return $perTag
+ }
+ $cols = $result.properties.columns
+ $costIdx = -1; $keyIdx = -1; $valIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i }
+ elseif ($n -eq 'tagkey') { $keyIdx = $i }
+ elseif ($n -eq 'tagvalue') { $valIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($keyIdx -eq -1) { $keyIdx = 1 }
+ if ($valIdx -eq -1) { $valIdx = 2 }
+ if ($currIdx -eq -1) { $currIdx = 3 }
+
+ foreach ($row in $result.properties.rows) {
+ $tagKey = if ($row[$keyIdx]) { $row[$keyIdx] } else { '' }
+ $tagVal = if ($row[$valIdx]) { $row[$valIdx] } else { '(untagged)' }
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ if (-not $perTag.ContainsKey($tagKey)) {
+ $perTag[$tagKey] = [System.Collections.Generic.List[PSCustomObject]]::new()
+ }
+ [void]$perTag[$tagKey].Add([PSCustomObject]@{ TagValue = $tagVal; Cost = $cost; Currency = $currency })
+ }
+ return $perTag
+ }
+
+ # Helper: Fire multiple REST calls in parallel using the shared runspace pool.
+ # Each call handles its own 429 retry internally, so pool slots may block
+ # briefly on throttle but other slots continue processing.
+ function Invoke-ParallelRestCalls {
+ param(
+ [array]$Calls, # Array of @{ Path; Body; SubId; SubName }
+ [int]$TimeoutSeconds = 90
+ )
+ $pendingJobs = [System.Collections.Generic.List[hashtable]]::new()
+ foreach ($call in $Calls) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($path, $payload)
+ for ($attempt = 0; $attempt -le 3; $attempt++) {
+ $params = @{ Path = $path; Method = 'POST'; ErrorAction = 'Stop' }
+ if ($payload) { $params['Payload'] = $payload }
+ try {
+ $r = Invoke-AzRestMethod @params
+ if ($r.StatusCode -ne 429) {
+ $hdrs = @{}
+ if ($r.Headers) { foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } }
+ return [PSCustomObject]@{ StatusCode = $r.StatusCode; Content = $r.Content; Headers = $hdrs }
+ }
+ # 429 — parse Retry-After or exponential backoff
+ $retryAfter = 10
+ if ($r.Headers -and $r.Headers['Retry-After']) {
+ $parsed = 0
+ if ([int]::TryParse($r.Headers['Retry-After'], [ref]$parsed)) { $retryAfter = [math]::Max($parsed, 5) }
+ }
+ else { $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) }
+ Start-Sleep -Seconds $retryAfter
+ }
+ catch {
+ return [PSCustomObject]@{ StatusCode = 0; Content = "{`"error`":{`"message`":`"$($_.Exception.Message)`"}}"; Headers = @{} }
+ }
+ }
+ return [PSCustomObject]@{ StatusCode = 429; Content = '{"error":{"message":"Rate limited after retries"}}'; Headers = @{} }
+ }).AddArgument($call.Path).AddArgument($call.Body)
+ $async = $ps.BeginInvoke()
+ [void]$pendingJobs.Add(@{ PS = $ps; Async = $async; Call = $call; Result = $null })
+ }
+
+ # Poll until all complete or timeout, keeping WPF UI responsive
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ $allDone = $true
+ foreach ($job in $pendingJobs) {
+ if ($null -ne $job.Result) { continue }
+ if ($job.Async.IsCompleted) {
+ try {
+ $raw = $job.PS.EndInvoke($job.Async)
+ $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ if (-not $resp) { $resp = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } }
+ if ($null -eq $resp.Content) { $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = @{} } }
+ $job.Result = $resp
+ }
+ catch {
+ $job.Result = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} }
+ }
+ $job.PS.Dispose()
+ }
+ else { $allDone = $false }
+ }
+ if ($allDone) { break }
+ Wait-WithDispatcher -Milliseconds 50
+ }
+
+ # Cleanup any timed-out jobs
+ foreach ($job in $pendingJobs) {
+ if ($null -eq $job.Result) {
+ try { $job.PS.Stop() } catch { }
+ $job.PS.Dispose()
+ $job.Result = [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Timeout"}}'; Headers = @{} }
+ }
+ }
+ return $pendingJobs
+ }
+
+ # === Query cost grouped by ResourceId, one call per subscription ======
+ # ResourceId grouping is supported at subscription scope across EA, MCA and
+ # pay-as-you-go (unlike TagKey/TagValue, which 400/408s on most sub types).
+ # This collapses the old tags x subs x timeframes call matrix down to a
+ # single call per subscription, then attributes each resource's cost to its
+ # tag values client-side. The scan is built to COMPLETE even when some subs
+ # fail: a failed subscription is recorded and skipped, never thrown.
+ $usedTimeframe = 'MonthToDate'
+ $timeframes = @('MonthToDate', 'Custom')
+
+ # Per-tag aggregation: tagName -> (tagValue -> accumulated cost)
+ $tagAgg = @{}
+ foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} }
+ $currencySeen = 'USD'
+ $subsQueried = 0
+ $subsFailed = 0
+ $grandTotal = 0.0
+
+ if (-not $Subscriptions -or $Subscriptions.Count -eq 0) {
+ Write-Host " No subscriptions available for cost-by-tag." -ForegroundColor Yellow
+ }
+ elseif ($tagsToQuery.Count -eq 0) {
+ Write-Host " No allocation tags found to break cost down by." -ForegroundColor Yellow
+ }
+ else {
+ foreach ($tf in $timeframes) {
+ # MonthToDate first; only fall back to last month (Custom) when MTD
+ # produced no cost at all (e.g. the first day of a new billing month).
+ if ($tf -eq 'Custom' -and $grandTotal -gt 0) { break }
+ if ($tf -eq 'Custom') {
+ Write-Host " MonthToDate returned no cost - retrying with last month..." -ForegroundColor Yellow
+ foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} }
+ $subsQueried = 0; $subsFailed = 0; $grandTotal = 0.0
+ }
+
+ $bodyObj = @{
+ type = 'ActualCost'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @( @{ type = 'Dimension'; name = 'ResourceId' } )
+ }
+ }
+ if ($tf -eq 'Custom') {
+ $lastMonthStart = (Get-Date).AddMonths(-1).ToString('yyyy-MM-01')
+ $lastMonthEnd = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd')
+ $bodyObj['timeframe'] = 'Custom'
+ $bodyObj['timePeriod'] = @{ from = $lastMonthStart; to = $lastMonthEnd }
+ }
+ else {
+ $bodyObj['timeframe'] = $tf
+ }
+ $body = $bodyObj | ConvertTo-Json -Depth 10
+
+ # Fan out across subscriptions in capped batches so a large tenant
+ # never floods the Cost Management API. Each call self-retries 429.
+ $batchSize = 8
+ $subList = @($Subscriptions)
+ for ($offset = 0; $offset -lt $subList.Count; $offset += $batchSize) {
+ $upper = [math]::Min($offset + $batchSize - 1, $subList.Count - 1)
+ $slice = @($subList[$offset..$upper])
+ $calls = @()
+ foreach ($sub in $slice) {
+ $calls += @{
+ Path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ Body = $body
+ SubId = $sub.Id
+ SubName = $sub.Name
+ }
+ }
+ $done = [math]::Min($offset + $slice.Count, $subList.Count)
+ Write-Host " Cost-by-tag: subscriptions $($offset + 1)-$done of $($subList.Count) ($tf)..." -ForegroundColor Cyan
+ $jobs = Invoke-ParallelRestCalls -Calls $calls -TimeoutSeconds 120
+ foreach ($pj in $jobs) {
+ $subResp = $pj.Result
+ if ($subResp.StatusCode -eq 200) {
+ $subsQueried++
+ $rows = Parse-ResourceIdRows -ResponseContent $subResp.Content
+ foreach ($row in $rows) {
+ $cost = $row.Cost
+ $grandTotal += $cost
+ if ($row.Currency) { $currencySeen = $row.Currency }
+
+ if ([string]::IsNullOrWhiteSpace($row.ResourceId)) {
+ foreach ($t in $tagsToQuery) {
+ $cur = [double]$tagAgg[$t]['(non-resource charges)']
+ $tagAgg[$t]['(non-resource charges)'] = $cur + $cost
+ }
+ continue
+ }
+
+ $ridLower = $row.ResourceId.ToLower()
+ $tags = $resTagMap[$ridLower]
+ if (-not $tags -or $tags.Count -eq 0) {
+ $pIdx = $ridLower.IndexOf('/providers/')
+ if ($pIdx -gt 0) {
+ $rgId = $ridLower.Substring(0, $pIdx)
+ $tags = $rgTagMap[$rgId]
+ }
+ }
+
+ foreach ($t in $tagsToQuery) {
+ $val = $null
+ if ($tags -and $tags.Count -gt 0) {
+ foreach ($k in $tags.Keys) {
+ if ($k -ieq $t) { $val = $tags[$k]; break }
+ }
+ }
+ if ([string]::IsNullOrWhiteSpace($val)) { $val = '(untagged resources)' }
+ $cur = [double]$tagAgg[$t][$val]
+ $tagAgg[$t][$val] = $cur + $cost
+ }
+ }
+ }
+ elseif ($subResp.StatusCode -eq 403) {
+ $subsFailed++
+ $script:costAccessIssue = 'MCA'
+ }
+ elseif ($subResp.StatusCode -eq 400) {
+ $subsFailed++
+ $errBody = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' }
+ if ($errBody -match 'AO View Charges') { $script:costAccessIssue = 'EA' }
+ }
+ else {
+ $subsFailed++
+ }
+ }
+ }
+
+ $usedTimeframe = $tf
+ }
+
+ if ($subsFailed -gt 0) {
+ Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data, $subsFailed skipped (timeout / throttle / access)." -ForegroundColor Yellow
+ }
+ else {
+ Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data." -ForegroundColor Green
+ }
+ }
+
+ # === Materialize aggregation into the result contract =================
+ # For each tag, emit { TagValue; Cost; Currency } rows sorted by cost desc.
+ # The synthetic "(untagged resources)" and "(non-resource charges)" values
+ # reconcile the breakdown back to the subscription invoice total.
+ foreach ($t in $tagsToQuery) {
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($val in $tagAgg[$t].Keys) {
+ $c = [math]::Round([double]$tagAgg[$t][$val], 2)
+ if ($c -eq 0) { continue }
+ [void]$rows.Add([PSCustomObject]@{ TagValue = $val; Cost = $c; Currency = $currencySeen })
+ }
+ $results[$t] = @($rows | Sort-Object Cost -Descending)
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = $tagsToQuery
+ CostByTag = $results
+ NoTagsFound = ($tagsToQuery.Count -eq 0)
+ UsedTimeframe = $usedTimeframe
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1
new file mode 100644
index 000000000..147413072
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1
@@ -0,0 +1,374 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COSTDATA.PS1
+# AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data
+###########################################################################
+# Purpose: Query Cost Management API at the management-group scope to
+# retrieve actual month-to-date spend and forecasted spend for
+# every subscription in a single efficient call.
+#
+# Approach: MG-scope queries avoid N per-subscription calls. We group
+# results by SubscriptionId so costs roll up correctly.
+#
+# Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage
+###########################################################################
+
+function Get-CostData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [switch]$RestrictToSelected
+ )
+
+ $costMap = @{}
+
+ # Restrict results to the subscriptions the user selected. MG-scope
+ # queries return every subscription under the management group, so we
+ # filter to the selected set to avoid showing unselected siblings.
+ $selectedSubs = $null
+ if ($Subscriptions -and $Subscriptions.Count -gt 0) {
+ $selectedSubs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($s in $Subscriptions) { if ($s.Id) { [void]$selectedSubs.Add([string]$s.Id) } }
+ }
+
+ # Resolve the management-group scope we can actually query for cost.
+ # Falls back to per-subscription only if no accessible MG returns cost data.
+ # When the user picked a subset of subscriptions we KEEP the single fast
+ # MG-scope query but add a server-side SubscriptionId filter so only the
+ # selected subs are returned - this avoids the slow per-subscription
+ # fan-out (N calls per timeframe) that triggers 429 throttling.
+ $mgScopeId = Resolve-CostMgId -TenantId $TenantId
+ $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null }
+ if (-not $mgScopeId) {
+ Write-Host " Querying actual costs (per-subscription)..." -ForegroundColor Cyan
+ return Get-CostDataPerSubscription -Subscriptions $Subscriptions
+ }
+
+ # -- Actual Cost (Month-to-Date) ------------------------------------
+ try {
+ Write-Host " Querying actual costs (MG scope)..." -ForegroundColor Cyan
+ $actualDataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'SubscriptionId' }
+ )
+ }
+ if ($subFilter) { $actualDataset['filter'] = $subFilter }
+ $actualBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = $actualDataset
+ } | ConvertTo-Json -Depth 10
+
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody
+
+ if ($response.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription."
+ }
+ if ($response.StatusCode -ne 200) {
+ throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription."
+ }
+
+ $result = ($response.Content | ConvertFrom-Json)
+
+ # Resolve column indices by name. The MG-scope response is not contractually
+ # ordered, and a reorder would silently attribute cost to the wrong sub.
+ $aCols = $result.properties.columns
+ $aCostIdx = -1; $aSubIdx = -1; $aCurIdx = -1
+ if ($aCols) {
+ for ($ci = 0; $ci -lt $aCols.Count; $ci++) {
+ $cn = ([string]$aCols[$ci].name).ToLower()
+ if ($cn -eq 'subscriptionid') { $aSubIdx = $ci }
+ elseif ($cn -eq 'currency') { $aCurIdx = $ci }
+ elseif ($cn -match 'cost|pretaxcost') { $aCostIdx = $ci }
+ }
+ }
+ if ($aCostIdx -eq -1) { $aCostIdx = 0 }
+ if ($aSubIdx -eq -1) { $aSubIdx = 1 }
+ if ($aCurIdx -eq -1) { $aCurIdx = 2 }
+
+ if ($result.properties.rows) {
+ foreach ($row in $result.properties.rows) {
+ $subId = $row[$aSubIdx]
+ $amount = [math]::Round($row[$aCostIdx], 2)
+ $currency = $row[$aCurIdx]
+
+ if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue }
+
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency }
+ }
+ $costMap[$subId].Actual = $amount
+ $costMap[$subId].Currency = $currency
+ }
+ }
+ }
+ catch {
+ Write-Warning "Actual cost query failed: $($_.Exception.Message)"
+ Write-Warning "Falling back to per-subscription queries."
+ $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions
+ return $costMap
+ }
+
+ # -- Forecasted Cost (Current Billing Period) -----------------------
+ # Try MG-scope first, fall back to per-subscription if it fails
+ $forecastSuccess = $false
+ try {
+ Write-Host " Querying forecast costs (MG scope)..." -ForegroundColor Cyan
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+
+ $forecastBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = $(
+ $fcDataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'SubscriptionId' }
+ )
+ }
+ if ($subFilter) { $fcDataset['filter'] = $subFilter }
+ $fcDataset
+ )
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $forecastPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01"
+ $fResponse = Invoke-AzRestMethodWithRetry -Path $forecastPath -Method POST -Payload $forecastBody
+
+ if ($fResponse.StatusCode -ne 200) {
+ throw "Forecast query returned HTTP $($fResponse.StatusCode)"
+ }
+
+ $fResult = ($fResponse.Content | ConvertFrom-Json)
+
+ if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) {
+ # The Forecast API with includeActualCost returns rows that may have
+ # a CostStatus column (Actual/Forecast). Sum all rows per subscription
+ # to get the full-month projected cost.
+ # Resolve column indices by name. The forecast endpoint may order
+ # columns differently and adds a CostStatus column, so positional
+ # access can mistake "Forecast"/"Actual" text for a subscription ID.
+ $fCols = $fResult.properties.columns
+ $fCostIdx = -1; $fSubIdx = -1
+ if ($fCols) {
+ for ($ci = 0; $ci -lt $fCols.Count; $ci++) {
+ $cn = ([string]$fCols[$ci].name).ToLower()
+ if ($cn -eq 'subscriptionid') { $fSubIdx = $ci }
+ elseif ($cn -match 'cost|pretaxcost') { $fCostIdx = $ci }
+ }
+ }
+ if ($fCostIdx -eq -1) { $fCostIdx = 0 }
+ if ($fSubIdx -eq -1) { $fSubIdx = 1 }
+
+ $forecastSums = @{}
+ foreach ($row in $fResult.properties.rows) {
+ $subId = [string]$row[$fSubIdx]
+ if ($subId -notmatch '^[0-9a-fA-F]{8}-') { continue }
+ if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue }
+ $amount = [double]$row[$fCostIdx]
+ if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 }
+ $forecastSums[$subId] += $amount
+ }
+ foreach ($subId in $forecastSums.Keys) {
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' }
+ }
+ $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2)
+ }
+ $forecastSuccess = $true
+ Write-Host " MG-scope forecast: got data for $($forecastSums.Count) subscriptions" -ForegroundColor Green
+ }
+ else {
+ throw "MG-scope forecast returned 0 rows"
+ }
+ }
+ catch {
+ Write-Warning "MG-scope forecast failed: $($_.Exception.Message)"
+ Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow
+ }
+
+ # Per-subscription forecast fallback
+ if (-not $forecastSuccess -and $Subscriptions) {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $subCount = $Subscriptions.Count
+ $i = 0
+ $hitCount = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i % [math]::Max(1, [int]($subCount / 10)) -eq 0) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying forecasts ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $total = 0
+ foreach ($row in $fRes.properties.rows) { $total += [double]$row[0] }
+ if (-not $costMap.ContainsKey($sub.Id)) {
+ $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' }
+ }
+ $costMap[$sub.Id].Forecast = [math]::Round($total, 2)
+ $hitCount++
+ }
+ }
+ }
+ catch {
+ # Forecast not available for this sub
+ }
+ }
+ Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' })
+ }
+
+ # Ensure any subs without forecast data default to actual
+ foreach ($subId in $costMap.Keys) {
+ if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) {
+ $costMap[$subId].Forecast = $costMap[$subId].Actual
+ }
+ }
+
+ return $costMap
+}
+
+# -- Fallback: Per-Subscription Cost Queries ----------------------------
+function Get-CostDataPerSubscription {
+ param([object[]]$Subscriptions)
+
+ $costMap = @{}
+ $subCount = $Subscriptions.Count
+ $skipForecast = ($subCount -gt 100) # For very large tenants, skip per-sub forecast to halve API calls
+ if ($skipForecast) {
+ Write-Host " Large tenant ($subCount subs): skipping per-sub forecast to reduce API calls" -ForegroundColor Yellow
+ }
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying costs ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement"
+ $resp = Invoke-AzRestMethodWithRetry -Path "$path/query?api-version=2023-11-01" -Method POST -Payload $body
+
+ $actual = 0; $currency = 'USD'
+ if ($resp.StatusCode -eq 200) {
+ $res = ($resp.Content | ConvertFrom-Json)
+ if ($res.properties.rows -and $res.properties.rows.Count -gt 0) {
+ $actual = [math]::Round($res.properties.rows[0][0], 2)
+ $currency = $res.properties.rows[0][1]
+ }
+ }
+ elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) {
+ $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' }
+ if ($errMsg -match 'AO View Charges') {
+ $script:costAccessIssue = 'EA'
+ Write-Warning " Cost data disabled for EA account owners. Enable 'AO View Charges' in the EA portal."
+ }
+ elseif ($resp.StatusCode -eq 403) {
+ $script:costAccessIssue = 'MCA'
+ Write-Warning " Cost data access denied. Verify Billing Profile Reader or Cost Management Reader role assignment."
+ }
+ }
+
+ $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency }
+
+ # Per-sub forecast (skipped for large tenants)
+ if (-not $skipForecast) {
+ try {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "$path/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $fAmount = [math]::Round($fRes.properties.rows[0][0], 2)
+ $costMap[$sub.Id].Forecast = $actual + $fAmount
+ }
+ }
+ }
+ catch {
+ # Forecast not available for all account types
+ }
+ }
+ }
+ catch {
+ Write-Warning " Cost query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ return $costMap
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1
new file mode 100644
index 000000000..4caeb7158
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1
@@ -0,0 +1,307 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COSTTREND.PS1
+# AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data
+###########################################################################
+# Purpose: Query Cost Management for the last 6 months of actual spend,
+# returning monthly totals suitable for a bar chart display.
+###########################################################################
+
+function Get-CostTrend {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [switch]$RestrictToSelected
+ )
+
+ Write-Host " Querying 6-month cost trend..." -ForegroundColor Cyan
+
+ $endDate = Get-Date -Day 1 # First of current month
+ $startDate = $endDate.AddMonths(-6)
+ $fromStr = $startDate.ToString('yyyy-MM-dd')
+ $toStr = (Get-Date).ToString('yyyy-MM-dd')
+
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $fromStr
+ to = $toStr
+ }
+ dataset = @{
+ granularity = 'Monthly'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $months = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $bySubscription = @{} # key = subId, value = sorted list of month entries
+
+ # When the user picked a subset of subscriptions we KEEP the single fast
+ # MG-scope grouped call but add a server-side SubscriptionId filter so the
+ # trend only includes the selected subs - avoids per-subscription fan-out.
+ $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null }
+
+ # Grouped variant: one MG-scope call returns the per-subscription matrix
+ # (month x subscription) in a single response, avoiding an N-subscription loop.
+ $groupedDataset = @{
+ granularity = 'Monthly'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'SubscriptionId' }
+ )
+ }
+ if ($subFilter) { $groupedDataset['filter'] = $subFilter }
+ $groupedBody = @{
+ type = 'ActualCost'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $fromStr
+ to = $toStr
+ }
+ dataset = $groupedDataset
+ } | ConvertTo-Json -Depth 10
+
+ # Helper: parse cost query rows into month entries
+ function Parse-CostRows {
+ param($Rows, $Columns)
+ $entries = [System.Collections.Generic.List[PSCustomObject]]::new()
+ if (-not $Rows) { return $entries }
+
+ $costIdx = -1; $dateIdx = -1; $currIdx = -1
+ if ($Columns) {
+ for ($ci = 0; $ci -lt $Columns.Count; $ci++) {
+ $n = $Columns[$ci].name.ToLower()
+ $t = $Columns[$ci].type.ToLower()
+ if ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci }
+ elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci }
+ elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci }
+ }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($dateIdx -eq -1) { $dateIdx = 1 }
+ if ($currIdx -eq -1) { $currIdx = 2 }
+
+ foreach ($row in $Rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $dateVal = $row[$dateIdx].ToString()
+ $dateClean = $dateVal -replace '[^0-9\-]', ''
+ if ($dateClean.Length -eq 8) {
+ $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null)
+ } else {
+ $parsed = [datetime]::Parse($dateVal)
+ }
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ [void]$entries.Add([PSCustomObject]@{
+ Month = $parsed.ToString('MMM yyyy')
+ MonthDate = $parsed
+ Cost = $cost
+ Currency = $currency
+ })
+ }
+ return $entries
+ }
+
+ try {
+ # Parse a SubscriptionId-grouped Monthly response into per-sub entries.
+ function Parse-GroupedCostRows {
+ param($Rows, $Columns)
+ $out = [System.Collections.Generic.List[PSCustomObject]]::new()
+ if (-not $Rows) { return $out }
+ $costIdx = -1; $dateIdx = -1; $currIdx = -1; $subIdx = -1
+ if ($Columns) {
+ for ($ci = 0; $ci -lt $Columns.Count; $ci++) {
+ $n = $Columns[$ci].name.ToLower()
+ $t = $Columns[$ci].type.ToLower()
+ if ($n -match 'subscriptionid') { $subIdx = $ci }
+ elseif ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci }
+ elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci }
+ elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci }
+ }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($dateIdx -eq -1) { $dateIdx = 1 }
+ foreach ($row in $Rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $dateVal = $row[$dateIdx].ToString()
+ $dateClean = $dateVal -replace '[^0-9\-]', ''
+ if ($dateClean.Length -eq 8) {
+ $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null)
+ } else {
+ $parsed = [datetime]::Parse($dateVal)
+ }
+ $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ $subId = if ($subIdx -ge 0 -and $subIdx -lt $row.Count) { [string]$row[$subIdx] } else { '' }
+ [void]$out.Add([PSCustomObject]@{
+ SubId = $subId
+ Month = $parsed.ToString('MMM yyyy')
+ MonthDate = $parsed
+ Cost = $cost
+ Currency = $currency
+ })
+ }
+ return $out
+ }
+
+ # Build the aggregate month list + per-sub breakdown from grouped entries.
+ function Set-TrendFromGrouped {
+ param($Entries)
+ $agg = @{}
+ foreach ($e in $Entries) {
+ if ($e.SubId) {
+ if (-not $bySubscription.ContainsKey($e.SubId)) {
+ $bySubscription[$e.SubId] = [System.Collections.Generic.List[PSCustomObject]]::new()
+ }
+ [void]$bySubscription[$e.SubId].Add([PSCustomObject]@{
+ Month = $e.Month; MonthDate = $e.MonthDate; Cost = $e.Cost; Currency = $e.Currency
+ })
+ }
+ $key = $e.MonthDate.ToString('yyyy-MM')
+ if (-not $agg.ContainsKey($key)) {
+ $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currency = $e.Currency }
+ }
+ $agg[$key].Cost += $e.Cost
+ }
+ foreach ($k in @($bySubscription.Keys)) {
+ $bySubscription[$k] = @($bySubscription[$k] | Sort-Object MonthDate)
+ }
+ foreach ($entry in $agg.GetEnumerator() | Sort-Object Key) {
+ [void]$months.Add([PSCustomObject]@{
+ Month = $entry.Value.Date.ToString('MMM yyyy')
+ MonthDate = $entry.Value.Date
+ Cost = [math]::Round($entry.Value.Cost, 2)
+ Currency = $entry.Value.Currency
+ })
+ }
+ }
+
+ $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 }
+
+ # -- Fast path: single subscription in scope ---------------------
+ # No management-group resolution needed - the subscription IS the
+ # scope. One direct query populates both the aggregate and the
+ # single-sub breakdown, avoiding the throttle-prone MG probe loop.
+ if ($subCount -eq 1) {
+ $only = $Subscriptions[0]
+ $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body
+ if ($subResp.StatusCode -eq 200) {
+ $subResult = ($subResp.Content | ConvertFrom-Json)
+ if ($subResult.properties.rows) {
+ $months = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns
+ $bySubscription[$only.Id] = @($months | Sort-Object MonthDate)
+ }
+ } else {
+ Write-Warning " Single-sub cost trend returned HTTP $($subResp.StatusCode)"
+ }
+ }
+ else {
+ # -- Multi-sub path: one grouped MG-scope query ---------------
+ # group by SubscriptionId so a single call returns the month x
+ # subscription matrix (aggregate + per-sub) in one response.
+ # When the user picked a subset of subscriptions, skip MG scope
+ # (whole management group) and use the per-subscription loop so the
+ # trend only reflects the selected subscriptions.
+ $mgScopeId = Resolve-CostMgId -TenantId $TenantId
+ $useMgScope = [bool]$mgScopeId
+ $groupedOk = $false
+
+ if ($useMgScope) {
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody
+ if ($response.StatusCode -eq 200) {
+ $result = ($response.Content | ConvertFrom-Json)
+ if ($result.properties.rows) {
+ $entries = Parse-GroupedCostRows -Rows $result.properties.rows -Columns $result.properties.columns
+ Set-TrendFromGrouped -Entries $entries
+ $groupedOk = ($months.Count -gt 0)
+ }
+ } else {
+ if ($response.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed }
+ Write-Warning " MG-scope grouped cost trend returned HTTP $($response.StatusCode) - falling back to per-sub"
+ $useMgScope = $false
+ }
+ }
+
+ # -- Fallback: per-subscription loop (MG scope unavailable) ---
+ if (-not $groupedOk -and $Subscriptions) {
+ $sampleErrors = 0
+ $sampleSize = [math]::Min(3, $subCount)
+ $aggTotals = @{} # used for aggregate if MG scope failed
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying cost trend ($i/$subCount subs)..."
+ }
+ }
+
+ $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body
+
+ if ($subResp.StatusCode -eq 200) {
+ $subResult = ($subResp.Content | ConvertFrom-Json)
+ if ($subResult.properties.rows) {
+ $subMonths = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns
+ $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate)
+
+ foreach ($sm in $subMonths) {
+ $key = $sm.MonthDate.ToString('yyyy-MM')
+ if (-not $aggTotals.ContainsKey($key)) {
+ $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency }
+ }
+ $aggTotals[$key].Cost += $sm.Cost
+ }
+ }
+ } else {
+ if ($i -le $sampleSize) { $sampleErrors++ }
+ }
+
+ if ($i -eq $sampleSize -and $sampleErrors -eq $sampleSize -and $subCount -gt $sampleSize) {
+ Write-Host " All $sampleSize sample subs returned errors - skipping remaining $($subCount - $sampleSize) subs" -ForegroundColor Yellow
+ break
+ }
+ }
+
+ if ($months.Count -eq 0 -and $aggTotals.Count -gt 0) {
+ foreach ($entry in $aggTotals.GetEnumerator() | Sort-Object Key) {
+ [void]$months.Add([PSCustomObject]@{
+ Month = $entry.Value.Date.ToString('MMM yyyy')
+ MonthDate = $entry.Value.Date
+ Cost = [math]::Round($entry.Value.Cost, 2)
+ Currency = $entry.Value.Currency
+ })
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning "Cost trend query failed: $($_.Exception.Message)"
+ }
+
+ # Sort by date
+ $sorted = @($months | Sort-Object MonthDate)
+
+ return [PSCustomObject]@{
+ Months = $sorted
+ BySubscription = $bySubscription
+ HasData = ($sorted.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1
new file mode 100644
index 000000000..fec7c73fa
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1
@@ -0,0 +1,168 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-IDLEVMS.PS1
+# AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection
+###########################################################################
+# Purpose: Query Azure Monitor metrics to find running VMs with very low
+# CPU and network utilization that Advisor hasn't flagged yet.
+# These are candidates for downsizing or shutting down.
+###########################################################################
+
+function Get-IdleVMs {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning for idle and underutilized VMs..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Find all running VMs ------------------------------------------
+ # Pull every VM with its power state so we can distinguish "no VMs at all"
+ # from "VMs exist but are all deallocated" (idle detection only applies to
+ # RUNNING VMs - a deallocated VM has no CPU to sample).
+ $totalVMs = 0
+ $deallocatedCount = 0
+ try {
+ $query = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| extend powerState = tostring(properties.extended.instanceView.powerState.code)
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ osType = properties.storageProfile.osDisk.osType,
+ powerState
+"@
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000
+ $allVMs = if ($result) { @($result.Data) } else { @() }
+ $totalVMs = $allVMs.Count
+ $runningVMs = @($allVMs | Where-Object { $_.powerState -eq 'PowerState/running' })
+ $deallocatedCount = $totalVMs - $runningVMs.Count
+ Write-Host " VMs found: $totalVMs ($($runningVMs.Count) running, $deallocatedCount stopped/deallocated)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Running VM query failed: $($_.Exception.Message)"
+ $runningVMs = @()
+ }
+
+ if ($runningVMs.Count -eq 0) {
+ $note = if ($totalVMs -gt 0) {
+ "$totalVMs VM(s) found but none are running ($deallocatedCount stopped/deallocated), so there is no CPU to sample for idle detection. Stopped/deallocated VMs still incur disk and IP cost - see scan_orphaned_resources."
+ }
+ else {
+ 'No virtual machines found in scope.'
+ }
+ return [PSCustomObject]@{
+ IdleVMs = @()
+ Count = 0
+ HasData = $false
+ ScannedVMs = 0
+ TotalVMs = $totalVMs
+ DeallocatedVMs = $deallocatedCount
+ Note = $note
+ }
+ }
+
+ # -- 2: Query 14-day avg CPU + Network for each VM -------------------
+ $armBase = Get-FinOpsArmEndpoint
+ $token = (Get-AzAccessToken -ResourceUrl $armBase).Token
+ $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' }
+ $now = (Get-Date).ToUniversalTime()
+ $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ $cpuThreshold = 5 # avg CPU < 5% = idle
+ $networkThreshold = 1048576 # < 1 MB/day total network = idle (14d * 1MB = 14MB)
+ $networkThreshold14d = $networkThreshold * 14
+
+ $vmCount = $runningVMs.Count
+ $vmIdx = 0
+ foreach ($vm in $runningVMs) {
+ $vmIdx++
+ if ($vmCount -gt 10 -and ($vmIdx -eq 1 -or $vmIdx % [math]::Max(1, [int]($vmCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Checking VM metrics ($vmIdx/$vmCount VMs)..."
+ }
+ }
+ $scope = "/subscriptions/$($vm.subscriptionId)/resourceGroups/$($vm.resourceGroup)/providers/Microsoft.Compute/virtualMachines/$($vm.name)"
+ try {
+ # Query CPU + Network In + Network Out in a single call
+ $metricUri = "$armBase$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=P14D"
+ $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
+ $metricData = ($resp.Content | ConvertFrom-Json)
+
+ $avgCpu = $null
+ $totalNetIn = 0
+ $totalNetOut = 0
+
+ foreach ($metric in $metricData.value) {
+ $metricName = $metric.name.value
+ foreach ($ts in $metric.timeseries) {
+ foreach ($dp in $ts.data) {
+ switch ($metricName) {
+ 'Percentage CPU' {
+ if ($dp.average -ne $null) { $avgCpu = $dp.average }
+ }
+ 'Network In Total' {
+ if ($dp.total) { $totalNetIn += $dp.total }
+ }
+ 'Network Out Total' {
+ if ($dp.total) { $totalNetOut += $dp.total }
+ }
+ }
+ }
+ }
+ }
+
+ $totalNetwork = $totalNetIn + $totalNetOut
+
+ # Classify: idle if CPU < threshold AND network < threshold
+ $isIdle = $false
+ $classification = $null
+
+ if ($avgCpu -ne $null -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) {
+ $isIdle = $true
+ $classification = 'Idle'
+ }
+ elseif ($avgCpu -ne $null -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) {
+ $isIdle = $true
+ $classification = 'Underutilized'
+ }
+
+ if ($isIdle) {
+ $dailyNetMB = [math]::Round($totalNetwork / 14 / 1MB, 2)
+ [void]$results.Add([PSCustomObject]@{
+ VMName = $vm.name
+ ResourceGroup = $vm.resourceGroup
+ SubscriptionId = $vm.subscriptionId
+ Location = $vm.location
+ VMSize = $vm.vmSize
+ OS = $vm.osType
+ AvgCPU14d = [math]::Round($avgCpu, 1)
+ NetworkPerDay = "$($dailyNetMB) MB"
+ Classification = $classification
+ Recommendation = if ($classification -eq 'Idle') { 'Deallocate or delete' } else { 'Downsize VM' }
+ })
+ }
+ }
+ catch {
+ # Metrics not available — skip this VM
+ }
+ }
+
+ Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray
+
+ [PSCustomObject]@{
+ IdleVMs = @($results)
+ Count = $results.Count
+ HasData = ($results.Count -gt 0)
+ ScannedVMs = $runningVMs.Count
+ TotalVMs = $totalVMs
+ DeallocatedVMs = $deallocatedCount
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1
new file mode 100644
index 000000000..694e110fe
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1
@@ -0,0 +1,185 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-LEGACYRESOURCES.PS1
+# AZURE FINOPS MULTITOOL - Legacy & Retiring Resource Detection
+###########################################################################
+# Purpose: Use Azure Resource Graph to surface resources running on legacy
+# or retiring SKUs that should be modernized: first-generation VM
+# families (A/D/F/G v1), HDD (Standard_LRS) managed disks,
+# unmanaged (VHD) disks, and Basic-SKU public IPs / load balancers
+# (retiring Sept 2025). Supports the FinOps modernization KPI.
+###########################################################################
+# Notes:
+# - RBAC: Reader on each subscription (Azure Resource Graph).
+# - "Legacy" VM families are v1 sizes with no version suffix (e.g.
+# Standard_D2 vs Standard_D2_v3) plus Basic_A / Standard_A0-A7 and the
+# G/GS series. These typically have a modern, cheaper successor.
+###########################################################################
+
+function Get-LegacyResources {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning for legacy and retiring resources..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $allLegacy = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Legacy (v1) VM families ---------------------------------------
+ try {
+ $vmQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| extend vmSize = tostring(properties.hardwareProfile.vmSize)
+| where vmSize matches regex @'(?i)^(Basic_A[0-9]+|Standard_A[0-7]|Standard_D[0-9]+|Standard_DS[0-9]+|Standard_F[0-9]+|Standard_G[0-9]+|Standard_GS[0-9]+)$'
+| project name, resourceGroup, subscriptionId, location, vmSize
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allLegacy.Add([PSCustomObject]@{
+ Category = 'Legacy VM Family'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.vmSize) (v1 - upgrade to current generation)"
+ Impact = 'High'
+ })
+ }
+ Write-Host " Legacy VM families: $($rows.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Legacy VM query failed: $($_.Exception.Message)"
+ }
+
+ # -- 2: Unmanaged (VHD) disks -----------------------------------------
+ try {
+ $vhdQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where isnotempty(properties.storageProfile.osDisk.vhd.uri)
+| project name, resourceGroup, subscriptionId, location,
+ vhd = tostring(properties.storageProfile.osDisk.vhd.uri)
+"@
+ $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allLegacy.Add([PSCustomObject]@{
+ Category = 'Unmanaged Disk'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = 'VM uses unmanaged (VHD) OS disk - migrate to managed disks'
+ Impact = 'High'
+ })
+ }
+ Write-Host " Unmanaged-disk VMs: $($rows.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Unmanaged disk query failed: $($_.Exception.Message)"
+ }
+
+ # -- 3: HDD (Standard_LRS) managed disks ------------------------------
+ try {
+ $hddQuery = @"
+resources
+| where type =~ 'microsoft.compute/disks'
+| where tostring(sku.name) =~ 'Standard_LRS'
+| where toint(properties.diskSizeGB) >= 128
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB, sku = sku.name
+"@
+ $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allLegacy.Add([PSCustomObject]@{
+ Category = 'HDD Managed Disk'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.diskSizeGb) GB HDD (Standard_LRS) - consider Standard/Premium SSD"
+ Impact = 'Low'
+ })
+ }
+ Write-Host " HDD managed disks: $($rows.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " HDD disk query failed: $($_.Exception.Message)"
+ }
+
+ # -- 4: Basic-SKU public IPs (retiring Sept 2025) ---------------------
+ try {
+ $pipQuery = @"
+resources
+| where type =~ 'microsoft.network/publicipaddresses'
+| where tostring(sku.name) =~ 'Basic'
+| project name, resourceGroup, subscriptionId, location, sku = sku.name
+"@
+ $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allLegacy.Add([PSCustomObject]@{
+ Category = 'Basic Public IP'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = 'Basic-SKU public IP (retires Sep 2025) - migrate to Standard'
+ Impact = 'High'
+ })
+ }
+ Write-Host " Basic public IPs: $($rows.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Basic public IP query failed: $($_.Exception.Message)"
+ }
+
+ # -- 5: Basic-SKU load balancers (retiring Sept 2025) -----------------
+ try {
+ $lbQuery = @"
+resources
+| where type =~ 'microsoft.network/loadbalancers'
+| where tostring(sku.name) =~ 'Basic'
+| project name, resourceGroup, subscriptionId, location, sku = sku.name
+"@
+ $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allLegacy.Add([PSCustomObject]@{
+ Category = 'Basic Load Balancer'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = 'Basic-SKU load balancer (retires Sep 2025) - migrate to Standard'
+ Impact = 'High'
+ })
+ }
+ Write-Host " Basic load balancers: $($rows.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Basic load balancer query failed: $($_.Exception.Message)"
+ }
+
+ $byCategory = @(
+ $allLegacy | Group-Object Category | ForEach-Object {
+ [PSCustomObject]@{ Category = $_.Name; Count = $_.Count }
+ } | Sort-Object Count -Descending
+ )
+
+ return [PSCustomObject]@{
+ HasData = ($allLegacy.Count -gt 0)
+ TotalCount = $allLegacy.Count
+ LegacyResources = @($allLegacy | Sort-Object @{ Expression = 'Impact'; Descending = $true }, Category)
+ ByCategory = $byCategory
+ ScannedSubs = $Subscriptions.Count
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1
new file mode 100644
index 000000000..122333278
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1
@@ -0,0 +1,156 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-MACCCOMMITMENT.PS1
+# AZURE FINOPS MULTITOOL - MACC Consumption Commitment Tracking
+###########################################################################
+# Purpose: Read the customer's Microsoft Azure Consumption Commitment (MACC)
+# and return a minimal readout: commitment, consumed, remaining,
+# % burned, and status for the current agreement period.
+#
+# Description:
+# Queries the Consumption Lots API at billing-account scope and filters for
+# lots whose source is 'ConsumptionCommitment' (the MACC):
+# 1. Discover reachable billing accounts and their agreement type
+# 2. For EA / MCA accounts, list consumption lots
+# 3. Keep MACC lots and compute commitment / consumed / remaining / % / status
+#
+# ── Parameters ──────────────────────────────────────────────────
+# Subscriptions Subscriptions in scope (used only to bound discovery)
+# AgreementType Detected agreement type from Get-ContractInfo
+#
+# Notes:
+# - The lots API is supported for Microsoft Customer Agreement (MCA) and
+# Direct Enterprise Agreement (EA) only. PAYGO / CSP / MSDN return nothing.
+# - Requires a billing role (Billing Account Reader / EA Reader) on the
+# billing account. Standard subscription RBAC does not grant access.
+#
+# Reference: https://learn.microsoft.com/en-us/rest/api/consumption/lots/list-by-billing-account
+###########################################################################
+
+function Get-MaccCommitment {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$AgreementType
+ )
+
+ Write-Host " Querying MACC consumption commitment..." -ForegroundColor Cyan
+
+ # -- Result shape (single source of truth) --------------------------
+ $result = [PSCustomObject]@{
+ HasMacc = $false
+ Applicable = $true
+ Reason = ''
+ Commitments = @()
+ }
+
+ # -- Step 0: Gate on agreement type ---------------------------------
+ # The lots API only returns data for MCA and Direct EA billing accounts.
+ if ($AgreementType -and $AgreementType -notin @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) {
+ $result.Applicable = $false
+ $result.Reason = "MACC tracking applies to Enterprise Agreement (EA) and Microsoft Customer Agreement (MCA) only. Detected agreement type: $AgreementType."
+ Write-Host " MACC not applicable for agreement type '$AgreementType'." -ForegroundColor DarkGray
+ return $result
+ }
+
+ # -- Step 1: Discover reachable billing accounts --------------------
+ $billingAccounts = @()
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $parsed = ($resp.Content | ConvertFrom-Json)
+ foreach ($a in $parsed.value) {
+ if ($a.properties.agreementType -in @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) {
+ $billingAccounts += [PSCustomObject]@{
+ Name = $a.name
+ DisplayName = $a.properties.displayName
+ Agreement = $a.properties.agreementType
+ }
+ }
+ }
+ }
+ } catch {
+ $result.Reason = "Could not list billing accounts: $($_.Exception.Message)"
+ Write-Warning " MACC: $($result.Reason)"
+ return $result
+ }
+
+ if ($billingAccounts.Count -eq 0) {
+ $result.Reason = 'No EA/MCA billing account is reachable. Assign a billing role (Billing Account Reader or EA Reader) to view MACC data.'
+ Write-Host " MACC: no reachable EA/MCA billing account." -ForegroundColor DarkGray
+ return $result
+ }
+
+ # -- Step 2: List MACC lots per billing account ---------------------
+ $lots = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $lotAccessDenied = $false # set if the lots call is forbidden (403) for any account
+ foreach ($ba in $billingAccounts) {
+ # Server-side filter to ConsumptionCommitment (the MACC) lots only. This
+ # mirrors the proven Cost Management data factory sample
+ # (MSBrett/ccm_datafactory), which reads the same lots endpoint.
+ $lotPath = "/providers/Microsoft.Billing/billingAccounts/$($ba.Name)/providers/Microsoft.Consumption/lots?api-version=2023-05-01&`$filter=source%20eq%20'ConsumptionCommitment'"
+ try {
+ $lotResp = Invoke-AzRestMethodWithRetry -Path $lotPath -Method GET
+ if ($lotResp.StatusCode -ne 200) {
+ # 403/401 = you can see the billing account but lack the billing
+ # role to read its consumption lots (the MACC). 404 = no lots
+ # exist for this account, which is genuinely "no MACC."
+ if ($lotResp.StatusCode -in @(401, 403)) { $lotAccessDenied = $true }
+ continue
+ }
+ $lotData = ($lotResp.Content | ConvertFrom-Json)
+ foreach ($lot in $lotData.value) {
+ $p = $lot.properties
+ if ("$($p.source)" -ne 'ConsumptionCommitment') { continue }
+
+ $currency = if ($p.billingCurrency) { $p.billingCurrency } elseif ($p.originalAmount.currency) { $p.originalAmount.currency } else { 'USD' }
+ $original = if ($null -ne $p.originalAmount.value) { [double]$p.originalAmount.value } else { 0 }
+
+ # The Lots API does not return a "used" amount. closedBalance is
+ # the amount REMAINING on the commitment, so consumed is simply
+ # originalAmount - closedBalance. This is the same calculation
+ # used by the Cost Management data factory sample
+ # (MSBrett/ccm_datafactory).
+ $remaining = if ($null -ne $p.closedBalance.value) { [double]$p.closedBalance.value } else { 0 }
+ $used = if ($original -gt 0) { [math]::Round($original - $remaining, 2) } else { 0 }
+ $pctUsed = if ($original -gt 0) { [math]::Round(($used / $original) * 100, 1) } else { 0 }
+
+ $lots.Add([PSCustomObject]@{
+ BillingAccount = $ba.DisplayName
+ Agreement = $ba.Agreement
+ Currency = $currency
+ Commitment = [math]::Round($original, 2)
+ Consumed = $used
+ Remaining = [math]::Round($remaining, 2)
+ PctUsed = $pctUsed
+ Status = if ($p.status) { $p.status } else { 'Unknown' }
+ StartDate = if ($p.startDate) { ([datetime]$p.startDate).ToString('yyyy-MM-dd') } else { '' }
+ ExpirationDate = if ($p.expirationDate) { ([datetime]$p.expirationDate).ToString('yyyy-MM-dd') } else { '' }
+ Estimated = [bool]$p.isEstimatedBalance
+ })
+ }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $lotAccessDenied = $true }
+ Write-Warning " MACC lot query failed for account $($ba.DisplayName): $($_.Exception.Message)"
+ }
+ }
+
+ if ($lots.Count -gt 0) {
+ $result.HasMacc = $true
+ $result.Commitments = @($lots)
+ Write-Host " Found $($lots.Count) MACC commitment lot(s)." -ForegroundColor Green
+ } elseif ($lotAccessDenied) {
+ $result.Reason = 'MACC data could not be read due to insufficient billing permissions. You can see the EA/MCA billing account, but reading its consumption commitment (lots) requires a billing role: for EA, Enterprise Administrator (read-only) or EA Reader at the enrollment scope; for MCA, Billing account reader or Billing profile reader. Standard subscription RBAC (Owner/Contributor/Reader) does not grant access. Ask a billing admin to assign one of these roles, then re-scan. Reference: https://learn.microsoft.com/azure/cost-management-billing/manage/understand-mca-roles'
+ Write-Host " MACC: billing access denied reading consumption lots." -ForegroundColor Yellow
+ } else {
+ $result.Reason = 'No MACC commitment found on the reachable EA/MCA billing account(s). This agreement may not include a consumption commitment.'
+ Write-Host " MACC: no consumption-commitment lots found." -ForegroundColor DarkGray
+ }
+
+ return $result
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1
new file mode 100644
index 000000000..b5347b5d7
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1
@@ -0,0 +1,176 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-OPTIMIZATIONADVICE.PS1
+# AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization
+###########################################################################
+# Purpose: Pull all cost optimization recommendations from Azure Advisor
+# across every subscription. Categorize by type: rightsize,
+# shutdown, delete, modernize.
+#
+# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations
+###########################################################################
+
+function Get-OptimizationAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $allRecs = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Build subscription ID list and name lookup
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name }
+
+ # Query all Advisor cost recommendations via Resource Graph (single call)
+ $query = @"
+advisorresources
+| where type == 'microsoft.advisor/recommendations'
+| where properties.category == 'Cost'
+| project subscriptionId,
+ shortDescriptionProblem = tostring(properties.shortDescription.problem),
+ shortDescriptionSolution = tostring(properties.shortDescription.solution),
+ impact = tostring(properties.impact),
+ impactedField = tostring(properties.impactedField),
+ impactedValue = tostring(properties.impactedValue),
+ annualSavings = tostring(properties.extendedProperties.annualSavingsAmount),
+ savingsAmount = tostring(properties.extendedProperties.savingsAmount),
+ savingsCurrency = tostring(properties.extendedProperties.savingsCurrency)
+"@
+
+ try {
+ Write-Host " Querying Advisor cost recommendations via Resource Graph..." -ForegroundColor Cyan
+ $allRows = [System.Collections.Generic.List[object]]::new()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } }
+ $skipToken = if ($result) { $result.SkipToken } else { $null }
+ } while ($skipToken)
+
+ Write-Host " Retrieved $($allRows.Count) Advisor cost recommendations." -ForegroundColor Cyan
+
+ foreach ($row in $allRows) {
+ $problem = $row.shortDescriptionProblem
+ $solution = $row.shortDescriptionSolution
+
+ # Skip reservation/savings plan recs (handled by Get-ReservationAdvice)
+ if ($problem -match 'reserv|savings plan') { continue }
+
+ # Categorize the recommendation
+ $catText = "$problem $solution"
+ $category = switch -Regex ($catText) {
+ 'right.?siz|resize|downsize|scale down' { 'Rightsize' }
+ 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' }
+ 'delet|unused|orphan|unattached' { 'Delete Unused' }
+ 'modern|upgrade|migrate|move to' { 'Modernize' }
+ 'burstable|B-series' { 'Rightsize' }
+ default { 'Other' }
+ }
+
+ $savings = $null
+ if ($row.annualSavings) {
+ $savings = [math]::Round([double]$row.annualSavings, 2)
+ }
+ elseif ($row.savingsAmount) {
+ $savings = [math]::Round([double]$row.savingsAmount, 2)
+ }
+
+ $subId = $row.subscriptionId
+ [void]$allRecs.Add([PSCustomObject]@{
+ Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId }
+ SubscriptionId = $subId
+ Category = $category
+ Impact = $row.impact
+ Problem = $problem
+ Solution = $solution
+ ResourceType = $row.impactedField
+ ResourceName = $row.impactedValue
+ AnnualSavings = $savings
+ Currency = $row.savingsCurrency
+ })
+ }
+ } catch {
+ Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)"
+ Write-Warning " Falling back to per-subscription REST calls..."
+
+ # Fallback: per-subscription REST API (slow but reliable)
+ foreach ($sub in $Subscriptions) {
+ try {
+ $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'"
+ $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET
+ if ($advResp.StatusCode -ne 200) { continue }
+ $advResult = ($advResp.Content | ConvertFrom-Json)
+
+ foreach ($item in $advResult.value) {
+ $rec = $item.properties
+ if ($rec.shortDescription.problem -match 'reserv|savings plan') { continue }
+
+ $catText = "$($rec.shortDescription.problem) $($rec.shortDescription.solution)"
+ $category = switch -Regex ($catText) {
+ 'right.?siz|resize|downsize|scale down' { 'Rightsize' }
+ 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' }
+ 'delet|unused|orphan|unattached' { 'Delete Unused' }
+ 'modern|upgrade|migrate|move to' { 'Modernize' }
+ 'burstable|B-series' { 'Rightsize' }
+ default { 'Other' }
+ }
+
+ $savings = $null
+ if ($rec.extendedProperties.annualSavingsAmount) {
+ $savings = [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2)
+ } elseif ($rec.extendedProperties.savingsAmount) {
+ $savings = [math]::Round([double]$rec.extendedProperties.savingsAmount, 2)
+ }
+
+ [void]$allRecs.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ Category = $category
+ Impact = $rec.impact
+ Problem = $rec.shortDescription.problem
+ Solution = $rec.shortDescription.solution
+ ResourceType = $rec.impactedField
+ ResourceName = $rec.impactedValue
+ AnnualSavings = $savings
+ Currency = $rec.extendedProperties.savingsCurrency
+ })
+ }
+ } catch {
+ Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Summarize by category ------------------------------------------
+ $byCat = $allRecs | Group-Object Category | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Name
+ Count = $_.Count
+ TotalSavings = [math]::Round(($_.Group | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum, 2)
+ }
+ }
+
+ $totalSavings = ($allRecs | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum
+
+ # -- Summarize by impact --------------------------------------------
+ $byImpact = $allRecs | Group-Object Impact | ForEach-Object {
+ [PSCustomObject]@{ Impact = $_.Name; Count = $_.Count }
+ }
+
+ return [PSCustomObject]@{
+ Recommendations = $allRecs
+ ByCategory = $byCat
+ ByImpact = $byImpact
+ TotalCount = $allRecs.Count
+ EstimatedAnnualSavings = [math]::Round($totalSavings, 2)
+ Summary = "$($allRecs.Count) optimization recommendations (est. `$$([math]::Round($totalSavings, 2))/yr savings)"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1
new file mode 100644
index 000000000..c2402202e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1
@@ -0,0 +1,219 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-ORPHANEDRESOURCES.PS1
+# AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection
+###########################################################################
+# Purpose: Use Azure Resource Graph to find resources that are costing
+# money but serving no purpose: orphaned disks, unattached IPs,
+# empty App Service Plans, unattached NICs, and stopped VMs
+# that are still incurring compute charges.
+###########################################################################
+
+function Get-OrphanedResources {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning for orphaned and idle resources..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $allOrphans = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Orphaned Managed Disks (no ownerVM) --------------------------
+ try {
+ $diskQuery = @"
+resources
+| where type =~ 'microsoft.compute/disks'
+| where managedBy == '' or isnull(managedBy)
+| where properties.diskState == 'Unattached'
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ sku = sku.name, diskState = properties.diskState,
+ type = 'Orphaned Disk'
+"@
+ $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Orphaned Disk'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.diskSizeGb) GB ($($r.sku))"
+ Impact = 'Medium'
+ })
+ }
+ Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Orphaned disk query failed: $($_.Exception.Message)"
+ }
+
+ # -- 2: Unattached Public IPs -----------------------------------------
+ try {
+ $pipQuery = @"
+resources
+| where type =~ 'microsoft.network/publicipaddresses'
+| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration)
+| where properties.natGateway == '' or isnull(properties.natGateway)
+| project name, resourceGroup, subscriptionId, location,
+ sku = sku.name, ipAddress = properties.ipAddress,
+ allocationMethod = properties.publicIPAllocationMethod,
+ type = 'Unattached Public IP'
+"@
+ $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Unattached Public IP'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.sku) - $($r.allocationMethod)"
+ Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' }
+ })
+ }
+ Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Unattached public IP query failed: $($_.Exception.Message)"
+ }
+
+ # -- 3: Unattached NICs -----------------------------------------------
+ try {
+ $nicQuery = @"
+resources
+| where type =~ 'microsoft.network/networkinterfaces'
+| where isnull(properties.virtualMachine) or properties.virtualMachine == ''
+| where isnull(properties.privateEndpoint) or properties.privateEndpoint == ''
+| project name, resourceGroup, subscriptionId, location,
+ enableAcceleratedNetworking = properties.enableAcceleratedNetworking,
+ type = 'Unattached NIC'
+"@
+ $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Unattached NIC'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "Accelerated: $($r.enableAcceleratedNetworking)"
+ Impact = 'Low'
+ })
+ }
+ Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Unattached NIC query failed: $($_.Exception.Message)"
+ }
+
+ # -- 4: Stopped (deallocated) VMs still on disk -----------------------
+ try {
+ $vmQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated'
+ or properties.extended.instanceView.powerState.code == 'PowerState/deallocated'
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ powerState = properties.extended.instanceView.powerState.displayStatus,
+ type = 'Deallocated VM'
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Deallocated VM'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.vmSize) - still incurs disk/IP costs"
+ Impact = 'Medium'
+ })
+ }
+ Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Deallocated VM query failed: $($_.Exception.Message)"
+ }
+
+ # -- 5: Empty App Service Plans (0 apps) ------------------------------
+ try {
+ $aspQuery = @"
+resources
+| where type =~ 'microsoft.web/serverfarms'
+| where properties.numberOfSites == 0
+| where sku.tier != 'Free' and sku.tier != 'Shared'
+| project name, resourceGroup, subscriptionId, location,
+ sku = strcat(sku.tier, ' / ', sku.name),
+ workers = properties.numberOfWorkers,
+ type = 'Empty App Service Plan'
+"@
+ $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Empty App Service Plan'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.sku), $($r.workers) worker(s), 0 apps"
+ Impact = 'High'
+ })
+ }
+ Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Empty ASP query failed: $($_.Exception.Message)"
+ }
+
+ # -- 6: Orphaned Snapshots (older than 30 days) -----------------------
+ try {
+ $snapshotCutoff = (Get-Date).AddDays(-30).ToString('yyyy-MM-dd')
+ $snapQuery = @"
+resources
+| where type =~ 'microsoft.compute/snapshots'
+| where properties.timeCreated < datetime('$snapshotCutoff')
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ timeCreated = properties.timeCreated,
+ type = 'Old Snapshot'
+"@
+ $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Old Snapshot (30d+)'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)"
+ Impact = 'Low'
+ })
+ }
+ Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Snapshot query failed: $($_.Exception.Message)"
+ }
+
+ # -- Summary by category --
+ $summary = $allOrphans | Group-Object Category | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Name
+ Count = $_.Count
+ }
+ }
+
+ return [PSCustomObject]@{
+ Orphans = @($allOrphans)
+ Summary = @($summary)
+ TotalCount = $allOrphans.Count
+ HasData = ($allOrphans.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1
new file mode 100644
index 000000000..fdea7c8f9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1
@@ -0,0 +1,298 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-POLICYINVENTORY.PS1
+# AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant
+###########################################################################
+# Purpose: Scan all policy assignments across the tenant's subscriptions
+# and return a summary of assigned policies, their effects,
+# scopes, and compliance state.
+#
+# Strategy: Resource Graph for assignments (1 paginated call) +
+# MG-scope Policy Insights for compliance (1 call).
+# Falls back to per-sub only for small tenants if above fail.
+###########################################################################
+
+function Get-PolicyInventory {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Scanning policy assignments across $subCount subscriptions..." -ForegroundColor Cyan
+
+ $allAssignments = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $complianceMap = @{}
+ $gotAssignments = $false
+ $gotCompliance = $false
+
+ # -- Strategy 1: ARM REST API for ALL effective assignments ----------
+ # Resource Graph policyresources at subscription scope only returns
+ # assignments AT that scope. The ARM Policy API returns ALL effective
+ # assignments including those inherited from management groups and
+ # the tenant root group.
+ try {
+ Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan
+ $seenIds = @{}
+ foreach ($sub in $Subscriptions) {
+ $subName = $sub.Name
+ $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01"
+ while ($nextLink) {
+ $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET
+ if ($resp.StatusCode -ne 200) { break }
+ $body = $resp.Content | ConvertFrom-Json
+ foreach ($a in $body.value) {
+ # De-duplicate (same MG assignment appears under each sub)
+ if ($seenIds.ContainsKey($a.id)) { continue }
+ $seenIds[$a.id] = $true
+
+ $props = $a.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' }
+ elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' }
+ else { 'Custom' }
+ $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') {
+ $Matches[1]
+ }
+ else { '' }
+
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name }
+ AssignmentId = $a.id
+ PolicyDefId = $defId
+ Scope = $scope
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $subName
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ # Handle pagination via nextLink
+ $nextLink = if ($body.nextLink) {
+ $body.nextLink -replace '^https://management\.azure\.com', ''
+ }
+ else { $null }
+ }
+ }
+
+ if ($allAssignments.Count -gt 0) {
+ $gotAssignments = $true
+ Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green
+ }
+ }
+ catch {
+ Write-Warning " ARM REST policy query failed: $($_.Exception.Message)"
+ }
+
+ # Fallback: Resource Graph if ARM REST didn't find any
+ if (-not $gotAssignments) {
+ try {
+ Write-Host " Falling back to Resource Graph for policy assignments..." -ForegroundColor Yellow
+ $argQuery = @"
+policyresources
+| where type =~ 'microsoft.authorization/policyassignments'
+| project id, name, properties, subscriptionId, type
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $skipToken = $null
+ $pageNum = 0
+ do {
+ $pageNum++
+ $result = Search-AzGraphSafe -Query $argQuery -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) {
+ foreach ($r in $result.Data) {
+ $props = $r.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' }
+ elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' }
+ else { 'Custom' }
+ $subName = $r.subscriptionId
+ $matchSub = $Subscriptions | Where-Object { $_.Id -eq $r.subscriptionId } | Select-Object -First 1
+ if ($matchSub) { $subName = $matchSub.Name }
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = if ($props.displayName) { $props.displayName } else { $r.name }
+ AssignmentId = $r.id
+ PolicyDefId = $defId
+ Scope = if ($props.scope) { $props.scope } else { ($r.id -replace '/providers/Microsoft\.Authorization/policyAssignments/.*', '') }
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $subName
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ $skipToken = $result.SkipToken
+ }
+ else { $skipToken = $null }
+ } while ($skipToken)
+ if ($allAssignments.Count -gt 0) {
+ $gotAssignments = $true
+ Write-Host " Resource Graph fallback: $($allAssignments.Count) assignments" -ForegroundColor Green
+ }
+ }
+ catch {
+ Write-Warning " Resource Graph policy query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Strategy 2: Resource Graph for compliance (tenant-wide, fast) ---
+ # The MG-scope PolicyInsights summarize REST API hangs indefinitely,
+ # and per-sub REST loops are slow on large tenants.
+ # Resource Graph policyresources table gives us compliance across ALL
+ # subscriptions in a single paginated call - fast and complete.
+ try {
+ Write-Host " Querying policy compliance via Resource Graph..." -ForegroundColor Cyan
+ $compQuery = @"
+policyresources
+| where type =~ 'microsoft.policyinsights/policystates'
+| extend complianceState = tostring(properties.complianceState)
+| summarize
+ Compliant = countif(complianceState =~ 'Compliant'),
+ NonCompliant = countif(complianceState =~ 'NonCompliant'),
+ Total = count()
+ by subscriptionId
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000
+
+ if ($compResult -and $compResult.Data -and $compResult.Data.Count -gt 0) {
+ foreach ($row in $compResult.Data) {
+ $subName = $row.subscriptionId
+ $matchSub = $Subscriptions | Where-Object { $_.Id -eq $row.subscriptionId } | Select-Object -First 1
+ if ($matchSub) { $subName = $matchSub.Name }
+
+ $complianceMap[$row.subscriptionId] = [PSCustomObject]@{
+ Subscription = $subName
+ SubscriptionId = $row.subscriptionId
+ TotalResources = $row.Total
+ NonCompliant = $row.NonCompliant
+ Compliant = $row.Compliant
+ # Not derivable from the compliance query; the REST fallback
+ # computes it. $null distinguishes "unknown" from a real zero.
+ PolicyCount = $null
+ }
+ }
+ $gotCompliance = $true
+ Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green
+ }
+ }
+ catch {
+ Write-Warning " Resource Graph compliance query failed: $($_.Exception.Message)"
+ }
+
+ # -- Compliance fallback: per-sub REST (only if ARG compliance failed) --
+ if (-not $gotCompliance) {
+ Write-Host " Falling back to per-sub compliance queries..." -ForegroundColor Yellow
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($subCount -gt 20 -and ($i % 10 -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Scanning policy compliance ($i/$subCount)..."
+ }
+ }
+ try {
+ $compPath = "/subscriptions/$($sub.Id)/providers/Microsoft.PolicyInsights/policyStates/latest/summarize?api-version=2019-10-01"
+ $compResp = Invoke-AzRestMethodWithRetry -Path $compPath -Method POST
+ if ($compResp.StatusCode -eq 200) {
+ $summary = ($compResp.Content | ConvertFrom-Json).value
+ if ($summary -and $summary.Count -gt 0) {
+ $s = $summary[0].results
+ $complianceMap[$sub.Id] = [PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ TotalResources = $s.resourceDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
+ NonCompliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'noncompliant' }).count
+ Compliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'compliant' }).count
+ PolicyCount = $s.policyDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
+ }
+ }
+ }
+ }
+ catch {
+ Write-Warning " Policy compliance failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Strategy 3: Per-sub fallback (only if Resource Graph failed) ---
+ if (-not $gotAssignments) {
+ Write-Host " Falling back to per-subscription policy scan..." -ForegroundColor Yellow
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Scanning policies ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $assignPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $assignPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $assignments = ($resp.Content | ConvertFrom-Json).value
+ foreach ($a in $assignments) {
+ $props = $a.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } else { 'Custom' }
+ if ($defId -match '/policySetDefinitions/') { $origin = 'Initiative' }
+
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = $props.displayName
+ AssignmentId = $a.id
+ PolicyDefId = $defId
+ Scope = $props.scope
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $sub.Name
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ }
+ }
+ catch {
+ Write-Warning " Policy assignments failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Deduplicate assignments by name + scope -----------------------
+ $seen = @{}
+ $unique = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($a in $allAssignments) {
+ $key = "$($a.AssignmentName)|$($a.Scope)"
+ if (-not $seen.ContainsKey($key)) {
+ $seen[$key] = $true
+ [void]$unique.Add($a)
+ }
+ }
+
+ # -- Compliance totals ---------------------------------------------
+ $totalCompliant = 0
+ $totalNonCompliant = 0
+ foreach ($c in $complianceMap.Values) {
+ $totalCompliant += $c.Compliant
+ $totalNonCompliant += $c.NonCompliant
+ }
+ $totalEvaluated = $totalCompliant + $totalNonCompliant
+ $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ Assignments = $unique
+ AssignmentCount = $unique.Count
+ ComplianceBySubMap = $complianceMap
+ CompliancePct = $compliancePct
+ TotalCompliant = $totalCompliant
+ TotalNonCompliant = $totalNonCompliant
+ TotalEvaluated = $totalEvaluated
+ HasComplianceData = ($totalEvaluated -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1
new file mode 100644
index 000000000..c622f0916
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1
@@ -0,0 +1,254 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-POLICYRECOMMENDATIONS.PS1
+# AZURE FINOPS MULTITOOL - FinOps Policy Recommendations
+###########################################################################
+# Purpose: Compare the customer's existing policy assignments against a
+# curated list of Microsoft-recommended FinOps/cost governance
+# policies (Azure built-in policy definitions).
+#
+# Sources:
+# - Azure built-in policies (Tags, General, Compute, Storage categories)
+# - Microsoft Cloud Adoption Framework cost governance guidance
+# - AzAdvertizer.net policy catalog reference
+#
+# Each recommendation includes the built-in policy definition ID so
+# it can be deployed directly from the GUI.
+###########################################################################
+
+function Get-PolicyRecommendations {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$ExistingAssignments # Policy assignment objects from Get-PolicyInventory
+ )
+
+ # -- Curated FinOps / Cost Governance Policies ----------------------
+ # These are Azure built-in policy definition IDs verified from
+ # https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies
+ $recommendedPolicies = @(
+ # === TAGGING & NAMING (CAF: Enforce Tagging and Naming) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/726aca4c-86e9-4b04-b0c5-073027359532'
+ DisplayName = 'Require a tag on resources'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Enforce tagging on all resources for cost allocation and chargeback visibility'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true }
+ @{ Name = 'tagValue'; Label = 'Tag value (leave blank for any value)'; Required = $false }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025'
+ DisplayName = 'Require a tag on resource groups'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Enforce tagging on resource groups for cost allocation at the container level'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070'
+ DisplayName = 'Inherit a tag from the resource group if missing'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Recommended'
+ DefaultEffect = 'Modify'
+ AllowedEffects = @('Modify','Disabled')
+ Purpose = 'Auto-inherit tags from resource group to child resources for consistent cost allocation'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/40df99da-1232-49b1-a39a-6da8d878f469'
+ DisplayName = 'Inherit a tag from the subscription if missing'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Recommended'
+ DefaultEffect = 'Modify'
+ AllowedEffects = @('Modify','Disabled')
+ Purpose = 'Auto-inherit tags from subscription to resources for top-level cost allocation'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true }
+ )
+ }
+
+ # === ALLOWED RESOURCE LOCATIONS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c'
+ DisplayName = 'Allowed locations'
+ Category = 'General'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict resource deployment to authorized Azure regions for compliance and cost control'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general'
+ Parameters = @(
+ @{ Name = 'listOfAllowedLocations'; Label = 'Allowed locations (comma-separated, e.g. eastus,westus2,centralus)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === RESTRICT VM SIZES (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3'
+ DisplayName = 'Allowed virtual machine size SKUs'
+ Category = 'Compute'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict VM sizes to prevent over-provisioning and control compute costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed VM SKUs (comma-separated, e.g. Standard_D2s_v3,Standard_B2ms)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ALLOWED STORAGE ACCOUNT SKUS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1'
+ DisplayName = 'Allowed storage account SKUs'
+ Category = 'Storage'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict storage account types to control costs and enforce standard tiers'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS,Standard_ZRS)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ALLOWED DISK SKUS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d'
+ DisplayName = 'Allowed managed disk SKUs'
+ Category = 'Compute'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict managed disk types to prevent costly Premium or Ultra disks where not needed'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed disk SKUs (comma-separated, e.g. Standard_LRS,StandardSSD_LRS,Premium_LRS)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === DEPLOY DIAGNOSTIC SETTINGS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7f89b1eb-583c-429a-8828-af049802c1d9'
+ DisplayName = 'Audit diagnostic setting'
+ Category = 'Monitoring'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'AuditIfNotExists'
+ AllowedEffects = @('AuditIfNotExists','Disabled')
+ Purpose = 'Automatically enable logging for diagnostics - ensures visibility into resource operations and costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#monitoring'
+ Parameters = @(
+ @{ Name = 'listOfResourceTypes'; Label = 'Resource types to audit (comma-separated, e.g. Microsoft.Compute/virtualMachines,Microsoft.Sql/servers,Microsoft.Storage/storageAccounts)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ADDITIONAL FINOPS-ALIGNED ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749'
+ DisplayName = 'Not allowed resource types'
+ Category = 'General'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Block expensive or unnecessary resource types to reduce cost sprawl'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general'
+ Parameters = @(
+ @{ Name = 'listOfResourceTypesNotAllowed'; Label = 'Resource types to block (comma-separated, e.g. Microsoft.Sql/servers,Microsoft.HDInsight/clusters)'; Required = $true; IsArray = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1'
+ DisplayName = 'Storage accounts should be limited by allowed SKUs'
+ Category = 'Storage'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Prevent Premium storage where Standard suffices to reduce storage costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS)'; Required = $true; IsArray = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/013e242c-8828-4970-87b3-ab247555486d'
+ DisplayName = 'Azure Backup should be enabled for Virtual Machines'
+ Category = 'Backup'
+ Pillar = 'Quantify'
+ Priority = 'Recommended'
+ DefaultEffect = 'AuditIfNotExists'
+ AllowedEffects = @('AuditIfNotExists','Disabled')
+ Purpose = 'Ensure VMs are backed up to prevent costly data loss recovery scenarios'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#backup'
+ }
+ )
+
+ # -- Match existing assignments against recommendations ------------
+ $existingDefIds = @{}
+ $existingNames = @{}
+ foreach ($a in $ExistingAssignments) {
+ if ($a.PolicyDefId) {
+ $existingDefIds[$a.PolicyDefId.ToLower()] = $true
+ }
+ if ($a.AssignmentName) {
+ $existingNames[$a.AssignmentName.ToLower()] = $true
+ }
+ }
+
+ $analysis = foreach ($rec in $recommendedPolicies) {
+ $foundById = $existingDefIds.ContainsKey($rec.PolicyDefId.ToLower())
+ $foundByName = $existingNames.ContainsKey($rec.DisplayName.ToLower())
+ $status = if ($foundById -or $foundByName) { 'Assigned' } else { 'Missing' }
+
+ [PSCustomObject]@{
+ DisplayName = $rec.DisplayName
+ Status = $status
+ Category = $rec.Category
+ Pillar = $rec.Pillar
+ Priority = $rec.Priority
+ DefaultEffect = $rec.DefaultEffect
+ AllowedEffects = $rec.AllowedEffects
+ Purpose = $rec.Purpose
+ PolicyDefId = $rec.PolicyDefId
+ Reference = $rec.Reference
+ Parameters = if ($rec.Parameters) { $rec.Parameters } else { @() }
+ }
+ }
+
+ $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' })
+ $assigned = @($analysis | Where-Object { $_.Status -eq 'Assigned' })
+
+ return [PSCustomObject]@{
+ Analysis = $analysis
+ Missing = $missing
+ Assigned = $assigned
+ CompliancePct = [math]::Round(($assigned.Count / $analysis.Count) * 100, 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1
new file mode 100644
index 000000000..bbfa6b38b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1
@@ -0,0 +1,233 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-RESERVATIONADVICE.PS1
+# AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations
+###########################################################################
+# Purpose: Pull RI (Reserved Instance) and Savings Plan recommendations
+# from Azure Advisor and the Reservation Recommendation API.
+#
+# Rate optimization (RI/SP) is the #1 FinOps quick win - typical
+# savings are 30-72% versus pay-as-you-go pricing.
+#
+# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations
+###########################################################################
+
+function Get-ReservationAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $allRecommendations = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Set to $true if an Advisor or reservation-recommendation query is
+ # forbidden (401/403) rather than simply returning no recommendations.
+ $accessDenied = $false
+
+ # Build subscription ID list and name lookup
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name }
+
+ # Query Advisor cost recommendations via Resource Graph (single call)
+ $query = @"
+advisorresources
+| where type == 'microsoft.advisor/recommendations'
+| where properties.category == 'Cost'
+| where properties.shortDescription.problem matches regex '(?i)reserv|savings plan|reserved instance'
+ or properties.shortDescription.solution matches regex '(?i)reserv|savings plan|reserved instance'
+| project subscriptionId,
+ shortDescriptionProblem = tostring(properties.shortDescription.problem),
+ shortDescriptionSolution = tostring(properties.shortDescription.solution),
+ impact = tostring(properties.impact),
+ impactedField = tostring(properties.impactedField),
+ impactedValue = tostring(properties.impactedValue),
+ annualSavings = tostring(properties.extendedProperties.annualSavingsAmount),
+ savingsCurrency = tostring(properties.extendedProperties.savingsCurrency),
+ term = tostring(properties.extendedProperties.term),
+ displaySKU = tostring(properties.extendedProperties.displaySKU),
+ region = tostring(properties.extendedProperties.region),
+ displayQty = tostring(properties.extendedProperties.displayQty),
+ recName = name
+"@
+
+ try {
+ Write-Host " Querying RI/SP recommendations via Resource Graph..." -ForegroundColor Cyan
+ $allRows = [System.Collections.Generic.List[object]]::new()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } }
+ $skipToken = if ($result) { $result.SkipToken } else { $null }
+ } while ($skipToken)
+
+ Write-Host " Retrieved $($allRows.Count) RI/SP recommendations." -ForegroundColor Cyan
+
+ foreach ($row in $allRows) {
+ $subId = $row.subscriptionId
+ $savings = if ($row.annualSavings) { [math]::Round([double]$row.annualSavings, 2) } else { $null }
+ $subName = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId }
+
+ # Savings Plans are subscription-scoped, flexible commitments: Advisor
+ # returns the subscription GUID as impactedValue and carries no
+ # SKU/region/qty. Resolve the GUID to the subscription name and label
+ # the flexible dimensions 'Any' so the row reads as a real scope-wide
+ # commitment rather than a bare ID with missing data. RIs keep their
+ # real SKU/region/qty. The savings come straight from Advisor.
+ $isSavingsPlan = ($row.shortDescriptionSolution -match '(?i)savings plan') -or ($row.shortDescriptionProblem -match '(?i)savings plan')
+ $isSubScope = $row.impactedField -match '(?i)subscriptions/subscriptions'
+ $resName = if ($isSubScope) { "$subName (subscription-wide)" } else { $row.impactedValue }
+ $sku = if ($row.displaySKU) { $row.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' }
+ $region = if ($row.region) { $row.region } elseif ($isSavingsPlan) { 'Any' } else { '-' }
+ $qty = if ($row.displayQty) { $row.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' }
+
+ [void]$allRecommendations.Add([PSCustomObject]@{
+ Subscription = $subName
+ SubscriptionId = $subId
+ Problem = $row.shortDescriptionProblem
+ Solution = $row.shortDescriptionSolution
+ Impact = $row.impact
+ Category = 'Reservation / Savings Plan'
+ ResourceType = $row.impactedField
+ ResourceName = $resName
+ SKU = $sku
+ Region = $region
+ Qty = $qty
+ AnnualSavings = $savings
+ Currency = $row.savingsCurrency
+ Term = $row.term
+ RecommendationId = $row.recName
+ })
+ }
+ } catch {
+ Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)"
+ Write-Warning " Falling back to per-subscription REST calls..."
+
+ foreach ($sub in $Subscriptions) {
+ try {
+ $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'"
+ $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET
+ if ($advResp.StatusCode -ne 200) {
+ if ($advResp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ continue
+ }
+ $advResult = ($advResp.Content | ConvertFrom-Json)
+
+ $riRecs = $advResult.value | Where-Object {
+ $_.properties.shortDescription.problem -match 'reserv|savings plan|reserved instance' -or
+ $_.properties.shortDescription.solution -match 'reserv|savings plan|reserved instance'
+ }
+
+ foreach ($item in $riRecs) {
+ $rec = $item.properties
+ $isSavingsPlan = ($rec.shortDescription.solution -match '(?i)savings plan') -or ($rec.shortDescription.problem -match '(?i)savings plan')
+ $isSubScope = $rec.impactedField -match '(?i)subscriptions/subscriptions'
+ $resName = if ($isSubScope) { "$($sub.Name) (subscription-wide)" } else { $rec.impactedValue }
+ $sku = if ($rec.extendedProperties.displaySKU) { $rec.extendedProperties.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' }
+ $region = if ($rec.extendedProperties.region) { $rec.extendedProperties.region } elseif ($isSavingsPlan) { 'Any' } else { '-' }
+ $qty = if ($rec.extendedProperties.displayQty) { $rec.extendedProperties.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' }
+ [void]$allRecommendations.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ Problem = $rec.shortDescription.problem
+ Solution = $rec.shortDescription.solution
+ Impact = $rec.impact
+ Category = 'Reservation / Savings Plan'
+ ResourceType = $rec.impactedField
+ ResourceName = $resName
+ SKU = $sku
+ Region = $region
+ Qty = $qty
+ AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) {
+ [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2)
+ } else { $null }
+ Currency = $rec.extendedProperties.savingsCurrency
+ Term = $rec.extendedProperties.term
+ RecommendationId = $item.name
+ })
+ }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Also try the Reservation Recommendation API --------------------
+ $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new()
+ try {
+ $rrPath = "/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=properties/scope eq 'Shared' and properties/lookBackPeriod eq 'Last30Days'"
+ $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET
+ if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true }
+ if (-not $rrResp -or -not $rrResp.Content) { throw "Reservation recommendation API returned no content (HTTP $($rrResp.StatusCode))" }
+ $rrResult = ($rrResp.Content | ConvertFrom-Json)
+
+ if ($rrResult.value) {
+ foreach ($item in $rrResult.value) {
+ $props = $item.properties
+ [void]$reservationRecs.Add([PSCustomObject]@{
+ ResourceType = $props.resourceType
+ SKU = $props.skuProperties.name
+ RecommendedQty = $props.recommendedQuantity
+ Term = $props.term
+ CostWithoutRI = if ($props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null }
+ CostWithRI = if ($props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null }
+ NetSavings = if ($props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null }
+ Currency = $props.currencyCode
+ Scope = $props.scope
+ LookBackPeriod = $props.lookBackPeriod
+ })
+ }
+ }
+ } catch {
+ if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true }
+ Write-Warning "Reservation recommendation API query failed (non-critical): $($_.Exception.Message)"
+ }
+
+ # -- De-duplicate Advisor records -----------------------------------
+ # Azure Advisor frequently emits several identical recommendation
+ # records that differ only by recommendation GUID (overlapping
+ # generation cycles). Collapse them on the meaningful tuple
+ # (sub + resource type + term + SKU + region + qty + savings) so the
+ # grid shows one row per distinct buy. DuplicateCount records how
+ # many Advisor records were rolled into each row, and de-duping also
+ # prevents the estimated-savings total from being inflated 3x.
+ $deduped = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $seenKeys = @{}
+ foreach ($rec in $allRecommendations) {
+ $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f `
+ $rec.SubscriptionId, $rec.ResourceType, $rec.Term, $rec.SKU, $rec.Region, $rec.Qty, $rec.AnnualSavings
+ if ($seenKeys.ContainsKey($key)) {
+ $seenKeys[$key].DuplicateCount++
+ }
+ else {
+ $rec | Add-Member -NotePropertyName DuplicateCount -NotePropertyValue 1 -Force
+ $seenKeys[$key] = $rec
+ [void]$deduped.Add($rec)
+ }
+ }
+ if ($allRecommendations.Count -ne $deduped.Count) {
+ Write-Host " Collapsed $($allRecommendations.Count) Advisor records into $($deduped.Count) distinct recommendations." -ForegroundColor Cyan
+ }
+ $allRecommendations = $deduped
+
+ # -- Aggregate savings ----------------------------------------------
+ $totalAnnualSavings = ($allRecommendations | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum
+
+ $denied = ($accessDenied -and $allRecommendations.Count -eq 0 -and $reservationRecs.Count -eq 0)
+
+ return [PSCustomObject]@{
+ AdvisorRecommendations = $allRecommendations
+ ReservationRecommendations = $reservationRecs
+ TotalAdvisorCount = $allRecommendations.Count
+ TotalReservationCount = $reservationRecs.Count
+ EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2)
+ AccessDenied = $denied
+ Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1
new file mode 100644
index 000000000..d7c18a23f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1
@@ -0,0 +1,376 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-RESOURCECOSTS.PS1
+# AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown
+###########################################################################
+# Purpose: Query Cost Management per subscription to retrieve actual and
+# forecasted spend grouped by individual resource.
+###########################################################################
+
+function Get-ResourceCosts {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ $CostData, # Per-sub cost data for forecast ratio distribution
+
+ [Parameter()]
+ [switch]$RestrictToSelected
+ )
+
+ # Guard: extract hashtable if pipeline pollution wrapped it in an array
+ if ($CostData -and $CostData -isnot [hashtable]) {
+ $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1]
+ }
+ if (-not $CostData) { $CostData = @{} }
+
+ $allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Linear month-to-date projection factor for per-resource forecasts. The
+ # per-resource Cost Management query only returns ActualCost (MTD), so a
+ # native forecast is not available. Project to month-end (Actual / dayOfMonth
+ # * daysInMonth) so Forecast is a real projection instead of equal to Actual.
+ $now = Get-Date
+ $daysInMonth = [DateTime]::DaysInMonth($now.Year, $now.Month)
+ $dayOfMonth = [math]::Max(1, $now.Day)
+ $forecastMult = $daysInMonth / $dayOfMonth
+
+ # Friendly resource type map
+ $typeMap = @{
+ 'microsoft.compute/virtualmachines' = 'Virtual Machine'
+ 'microsoft.compute/disks' = 'Managed Disk'
+ 'microsoft.network/loadbalancers' = 'Load Balancer'
+ 'microsoft.network/applicationgateways' = 'App Gateway'
+ 'microsoft.network/azurefirewalls' = 'Azure Firewall'
+ 'microsoft.network/publicipaddresses' = 'Public IP'
+ 'microsoft.network/virtualnetworkgateways' = 'VNet Gateway'
+ 'microsoft.network/virtualnetworks' = 'Virtual Network'
+ 'microsoft.network/privatednszones' = 'Private DNS Zone'
+ 'microsoft.network/networkinterfaces' = 'NIC'
+ 'microsoft.network/networksecuritygroups' = 'NSG'
+ 'microsoft.network/bastionhosts' = 'Bastion'
+ 'microsoft.containerservice/managedclusters' = 'AKS Cluster'
+ 'microsoft.sql/servers' = 'SQL Server'
+ 'microsoft.sql/servers/databases' = 'SQL Database'
+ 'microsoft.storage/storageaccounts' = 'Storage Account'
+ 'microsoft.web/sites' = 'App Service'
+ 'microsoft.web/serverfarms' = 'App Service Plan'
+ 'microsoft.keyvault/vaults' = 'Key Vault'
+ 'microsoft.operationalinsights/workspaces' = 'Log Analytics'
+ 'microsoft.insights/components' = 'App Insights'
+ 'microsoft.recoveryservices/vaults' = 'Recovery Vault'
+ 'microsoft.automation/automationaccounts' = 'Automation Account'
+ 'microsoft.dbformysql/flexibleservers' = 'MySQL Flexible'
+ 'microsoft.dbforpostgresql/flexibleservers' = 'PostgreSQL Flexible'
+ 'microsoft.cosmosdb/databaseaccounts' = 'Cosmos DB'
+ 'microsoft.cache/redis' = 'Redis Cache'
+ 'microsoft.cdn/profiles' = 'CDN / Front Door'
+ 'microsoft.containerregistry/registries' = 'Container Registry'
+ 'microsoft.apimanagement/service' = 'API Management'
+ 'microsoft.eventgrid/topics' = 'Event Grid Topic'
+ 'microsoft.servicebus/namespaces' = 'Service Bus'
+ 'microsoft.logic/workflows' = 'Logic App'
+ 'microsoft.security/pricings' = 'Defender Plan'
+ 'microsoft.hybridcompute/machines' = 'Arc Server'
+ }
+
+ $gotMgData = $false
+
+ # -- Strategy 1: MG-scope query (1-10 API calls instead of 300+) ----
+ # When the user picked a subset of subscriptions we KEEP the fast MG-scope
+ # query but add a server-side SubscriptionId filter so only the selected
+ # subs' resources are returned - avoids the slow per-subscription fan-out
+ # that triggers 429 throttling.
+ $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null }
+ $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null }
+ if ($mgScopeId) {
+ try {
+ Write-Host " Querying resource costs (MG scope)..." -ForegroundColor Cyan
+ $rcDataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ResourceId' }
+ @{ type = 'Dimension'; name = 'ResourceGroupName' }
+ )
+ }
+ if ($subFilter) { $rcDataset['filter'] = $subFilter }
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = $rcDataset
+ } | ConvertTo-Json -Depth 10
+
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body
+
+ if ($resp.StatusCode -eq 200) {
+ $result = ($resp.Content | ConvertFrom-Json)
+ $cols = @{}
+ for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) {
+ $cols[$result.properties.columns[$colIdx].name] = $colIdx
+ }
+
+ $page = $result
+ $pageNum = 0
+ do {
+ $pageNum++
+ if ($page.properties.rows) {
+ if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) {
+ Write-Host " Page $pageNum ($($page.properties.rows.Count) rows)..." -ForegroundColor Gray
+ }
+ foreach ($row in $page.properties.rows) {
+ $cost = [math]::Round($row[$cols['Cost']], 2)
+ $currency = $row[$cols['Currency']]
+ $resourceId = $row[$cols['ResourceId']]
+ $rg = $row[$cols['ResourceGroupName']]
+
+ $resType = 'Unknown'
+ $resName = $resourceId
+ if ($resourceId -match '/providers/(.+)/([^/]+)$') {
+ $providerType = $Matches[1].ToLower()
+ $resName = $Matches[2]
+ $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' }
+ }
+
+ [void]$allRows.Add([PSCustomObject]@{
+ Subscription = ''
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resourceId
+ Actual = $cost
+ Forecast = [math]::Round($cost * $forecastMult, 2)
+ Currency = $currency
+ })
+ }
+ }
+ if ($page.properties.nextLink) {
+ $nextUri = [System.Uri]$page.properties.nextLink
+ $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET
+ if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) }
+ else { break }
+ }
+ else { break }
+ } while ($true)
+
+ if ($allRows.Count -gt 0) {
+ $gotMgData = $true
+ Write-Host " MG scope: $($allRows.Count) resources across $pageNum page(s)" -ForegroundColor Green
+
+ # Populate subscription names from ARM resource ID
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id.ToLower()] = $sub.Name }
+ foreach ($r in $allRows) {
+ if ($r.ResourcePath -match '/subscriptions/([^/]+)/') {
+ $sid = $Matches[1].ToLower()
+ $r.Subscription = if ($subNameMap.ContainsKey($sid)) { $subNameMap[$sid] } else { $sid }
+ }
+ }
+
+ # Apply forecast ratios from CostData (actual + forecast per sub)
+ if ($CostData) {
+ $ratios = @{}
+ foreach ($entry in $CostData.GetEnumerator()) {
+ $a = $entry.Value.Actual
+ $f = $entry.Value.Forecast
+ if ($a -gt 0 -and $f -gt $a) { $ratios[$entry.Key.ToLower()] = $f / $a }
+ }
+ foreach ($r in $allRows) {
+ if ($r.ResourcePath -match '/subscriptions/([^/]+)/') {
+ $sid = $Matches[1].ToLower()
+ if ($ratios.ContainsKey($sid)) {
+ $r.Forecast = [math]::Round($r.Actual * $ratios[$sid], 2)
+ }
+ }
+ }
+ }
+ }
+ }
+ else {
+ if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed }
+ Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)"
+ }
+ }
+ catch {
+ Write-Warning " MG-scope resource cost query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Strategy 2: Per-subscription fallback (only if MG scope failed) -
+ if (-not $gotMgData) {
+ $subCount = $Subscriptions.Count
+ $skipForecast = ($subCount -gt 50) # For large tenants, skip per-sub forecast to halve API calls
+ if ($skipForecast) {
+ Write-Host " Large tenant ($subCount subs): skipping per-resource forecast to reduce API calls" -ForegroundColor Yellow
+ }
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying resource costs ($i/$subCount subs)..."
+ }
+ }
+ $basePath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement"
+
+ # -- Actual cost grouped by resource ----------------------------
+ $actualMap = @{}
+ try {
+ Write-Host " Querying resource costs for $($sub.Name)..." -ForegroundColor Cyan
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ResourceId' }
+ @{ type = 'Dimension'; name = 'ResourceGroupName' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body
+
+ if ($resp.StatusCode -eq 200) {
+ $result = ($resp.Content | ConvertFrom-Json)
+
+ # Build column index from response metadata (same for all pages)
+ # Distinct loop variable: $i is the outer per-subscription counter.
+ $cols = @{}
+ for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) {
+ $cols[$result.properties.columns[$colIdx].name] = $colIdx
+ }
+
+ # Process all pages (Cost Management API paginates at ~5000 rows)
+ $page = $result
+ do {
+ if ($page.properties.rows) {
+ foreach ($row in $page.properties.rows) {
+ $cost = [math]::Round($row[$cols['Cost']], 2)
+ $currency = $row[$cols['Currency']]
+ $resourceId = $row[$cols['ResourceId']]
+ $rg = $row[$cols['ResourceGroupName']]
+
+ # Extract resource type from ARM ID
+ $resType = 'Unknown'
+ $resName = $resourceId
+ if ($resourceId -match '/providers/(.+)/([^/]+)$') {
+ $providerType = $Matches[1].ToLower()
+ $resName = $Matches[2]
+ $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' }
+ }
+
+ $actualMap[$resourceId] = [PSCustomObject]@{
+ Subscription = $sub.Name
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resourceId
+ Actual = $cost
+ Forecast = [math]::Round($cost * $forecastMult, 2)
+ Currency = $currency
+ }
+ }
+ }
+ # Follow pagination link if present
+ if ($page.properties.nextLink) {
+ $uri = [System.Uri]$page.properties.nextLink
+ $nResp = Invoke-AzRestMethodWithRetry -Path $uri.PathAndQuery -Method GET
+ if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) }
+ else { break }
+ }
+ else { break }
+ } while ($true)
+ }
+ }
+ catch {
+ Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+
+ # -- Forecast: use subscription-level forecast ratio -------------
+ # The forecast API does not reliably support ResourceId grouping,
+ # so we get the sub-level forecast and distribute proportionally.
+ # For large tenants (50+ subs), skip per-sub forecast API calls
+ # and use CostData ratios if available.
+ $subTotalActual = 0
+ foreach ($entry in $actualMap.Values) { $subTotalActual += $entry.Actual }
+
+ $subForecast = $subTotalActual # default: same as actual
+
+ # Use CostData ratio if available (avoids extra API call)
+ if ($CostData -and $CostData.ContainsKey($sub.Id)) {
+ $cd = $CostData[$sub.Id]
+ if ($cd.Forecast -gt $cd.Actual -and $cd.Actual -gt 0) {
+ $subForecast = $subTotalActual * ($cd.Forecast / $cd.Actual)
+ }
+ }
+ elseif (-not $skipForecast) {
+ # Only call forecast API for small tenants without CostData
+ try {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "$basePath/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+
+ if ($fResp.StatusCode -eq 200) {
+ $fResult = ($fResp.Content | ConvertFrom-Json)
+ if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) {
+ $forecastTotal = 0
+ foreach ($row in $fResult.properties.rows) {
+ $forecastTotal += [double]$row[0]
+ }
+ $subForecast = [math]::Round($forecastTotal, 2)
+ }
+ }
+ }
+ catch {
+ # Forecast not available for all account types
+ }
+ }
+
+ # Apply forecast ratio proportionally to each resource
+ if ($subTotalActual -gt 0 -and $subForecast -gt $subTotalActual) {
+ $ratio = $subForecast / $subTotalActual
+ foreach ($entry in $actualMap.Values) {
+ $entry.Forecast = [math]::Round($entry.Actual * $ratio, 2)
+ }
+ }
+
+ # Collect rows from this sub
+ foreach ($entry in $actualMap.Values) {
+ [void]$allRows.Add($entry)
+ }
+ }
+ } # end per-sub fallback
+
+ return $allRows
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1
new file mode 100644
index 000000000..ee9482330
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1
@@ -0,0 +1,375 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-SAVINGSREALIZED.PS1
+# AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments
+###########################################################################
+# Purpose: Calculate how much existing RIs, Savings Plans, and AHB have
+# already saved vs pay-as-you-go. This is the "value delivered"
+# metric that FinOps teams report to leadership.
+###########################################################################
+
+function Get-SavingsRealized {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object]$CommitmentData
+ )
+
+ Write-Host " Calculating savings already realized..." -ForegroundColor Cyan
+
+ $riSavings = 0
+ $spSavings = 0
+ $ahbSavings = 0
+ # Amortized cost split by pricing model, used for commitment COVERAGE
+ # (how much of eligible spend rides on a commitment) - distinct from the
+ # savings amounts above. Spot is excluded from the eligible base because it
+ # cannot be covered by a reservation or savings plan.
+ $committedAmort = 0.0
+ $onDemandAmort = 0.0
+ $spotAmort = 0.0
+ $details = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- Short-circuit: skip RI/SP queries if no commitments exist -------
+ $hasCommitments = $true
+ if ($CommitmentData -and $CommitmentData.PSObject.Properties['HasData']) {
+ if (-not $CommitmentData.HasData) {
+ $hasCommitments = $false
+ Write-Host " No reservations or savings plans detected — skipping commitment savings queries" -ForegroundColor DarkGray
+ }
+ }
+
+ $gotMgData = $false
+ $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 }
+
+ # Map subscription Id -> friendly name so MG-grouped rows keep per-sub attribution
+ $subNameById = @{}
+ foreach ($s in $Subscriptions) { $subNameById[$s.Id] = $s.Name }
+
+ # Build a Cost Management query body with the requested grouping dimensions
+ function New-SavingsQueryBody {
+ param([string]$Type, [string[]]$Dimensions)
+ @{
+ type = $Type
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @($Dimensions | ForEach-Object { @{ type = 'Dimension'; name = $_ } })
+ }
+ } | ConvertTo-Json -Depth 10
+ }
+
+ # Resolve named column indices from a Cost Management query result
+ function Get-SavingsColMap {
+ param($Columns)
+ $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1 }
+ for ($c = 0; $c -lt $Columns.Count; $c++) {
+ switch ($Columns[$c].name) {
+ 'Cost' { $map.Cost = $c }
+ 'ChargeType' { $map.ChargeType = $c }
+ 'PricingModel' { $map.PricingModel = $c }
+ 'SubscriptionId' { $map.SubscriptionId = $c }
+ }
+ }
+ $map
+ }
+
+ # Parse an ActualCost result for UnusedReservation waste; returns detail rows
+ function Read-SavingsActual {
+ param($Result)
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ if (-not $Result -or -not $Result.properties.rows) { return $rows }
+ $m = Get-SavingsColMap -Columns $Result.properties.columns
+ foreach ($row in $Result.properties.rows) {
+ $charge = if ($m.ChargeType -ge 0) { [string]$row[$m.ChargeType] } else { '' }
+ if ($charge -match 'UnusedReservation') {
+ $sub = 'All (MG scope)'
+ if ($m.SubscriptionId -ge 0) {
+ $sid = [string]$row[$m.SubscriptionId]
+ $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid }
+ }
+ $rows.Add([PSCustomObject]@{
+ Subscription = $sub
+ Category = 'Unused Reservation'
+ Amount = [math]::Round([double]$row[$m.Cost], 2)
+ Type = 'Waste'
+ })
+ }
+ }
+ return $rows
+ }
+
+ # Parse an AmortizedCost result for RI/SP benefit; returns rows + savings totals
+ function Read-SavingsAmort {
+ param($Result)
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $ri = 0.0; $sp = 0.0
+ $committed = 0.0; $onDemand = 0.0; $spot = 0.0
+ if ($Result -and $Result.properties.rows) {
+ $m = Get-SavingsColMap -Columns $Result.properties.columns
+ foreach ($row in $Result.properties.rows) {
+ $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' }
+ $cost = [math]::Round([double]$row[$m.Cost], 2)
+ $sub = 'All (MG scope)'
+ if ($m.SubscriptionId -ge 0) {
+ $sid = [string]$row[$m.SubscriptionId]
+ $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid }
+ }
+ if ($pm -match 'Reservation') {
+ $ri += $cost * 0.4
+ $committed += $cost
+ $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment' })
+ }
+ elseif ($pm -match 'SavingsPlan') {
+ $sp += $cost * 0.25
+ $committed += $cost
+ $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment' })
+ }
+ elseif ($pm -match 'Spot') { $spot += $cost }
+ elseif ($pm) { $onDemand += $cost }
+ }
+ }
+ return [PSCustomObject]@{ Rows = $rows; RI = $ri; SP = $sp; Committed = $committed; OnDemand = $onDemand; Spot = $spot }
+ }
+
+ if ($hasCommitments -and $subCount -eq 1) {
+ # -- Strategy 0: single-subscription fast path (skip MG resolution entirely) --
+ $only = $Subscriptions[0]
+ try {
+ Write-Host " Calculating savings (single subscription, direct scope)..." -ForegroundColor Cyan
+ $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+
+ $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType'))
+ if ($actualResp.StatusCode -eq 200) {
+ foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) {
+ $d.Subscription = $only.Name; [void]$details.Add($d)
+ }
+ }
+
+ $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel'))
+ if ($amortResp.StatusCode -eq 200) {
+ $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json)
+ foreach ($d in $parsed.Rows) { $d.Subscription = $only.Name; [void]$details.Add($d) }
+ $riSavings += $parsed.RI
+ $spSavings += $parsed.SP
+ $committedAmort += $parsed.Committed
+ $onDemandAmort += $parsed.OnDemand
+ $spotAmort += $parsed.Spot
+ }
+
+ $gotMgData = $true
+ Write-Host " Single-subscription savings calculated (2 API calls)" -ForegroundColor Green
+ } catch {
+ Write-Warning " Single-subscription savings query failed: $($_.Exception.Message)"
+ }
+ }
+ elseif ($hasCommitments) {
+ # -- Strategy 1: MG-scope grouped by SubscriptionId (2 calls, per-sub attribution) --
+ $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null }
+ if ($mgScopeId) {
+ try {
+ Write-Host " Calculating savings (MG scope, grouped by subscription)..." -ForegroundColor Cyan
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+
+ $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType'))
+ if ($actualResp.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)"
+ }
+ if ($actualResp.StatusCode -eq 200) {
+ foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { [void]$details.Add($d) }
+ }
+
+ $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel'))
+ if ($amortResp.StatusCode -eq 200) {
+ $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json)
+ foreach ($d in $parsed.Rows) { [void]$details.Add($d) }
+ $riSavings += $parsed.RI
+ $spSavings += $parsed.SP
+ $committedAmort += $parsed.Committed
+ $onDemandAmort += $parsed.OnDemand
+ $spotAmort += $parsed.Spot
+ }
+
+ $gotMgData = $true
+ Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green
+ } catch {
+ Write-Warning " MG-scope savings query failed: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Strategy 2: Per-subscription fallback (only if MG/direct scope unavailable) --
+ if ($hasCommitments -and -not $gotMgData) {
+ # -- Step 1: Query amortized vs actual to find RI/SP benefit amounts --
+ # The difference between ActualCost and AmortizedCost reveals commitment savings
+ $subCount = $Subscriptions.Count
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($subCount -gt 5 -and ($i -eq 1 -or $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Calculating savings ($i/$subCount subs)..."
+ }
+ }
+ try {
+ # Get ActualCost MonthToDate
+ $actualBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ChargeType' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody
+
+ if ($actualResp.StatusCode -eq 200) {
+ $actualResult = ($actualResp.Content | ConvertFrom-Json)
+ if ($actualResult.properties.rows) {
+ foreach ($row in $actualResult.properties.rows) {
+ $chargeType = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+
+ # RI/SP purchases show as separate charge types
+ if ($chargeType -match 'UnusedReservation') {
+ # This is wasted money — unused RI capacity
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Unused Reservation'
+ Amount = $cost
+ Type = 'Waste'
+ })
+ }
+ }
+ }
+ }
+
+ # Get benefit usage via the reservation transactions or amortized view
+ $amortBody = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'PricingModel' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody
+ if ($amortResp.StatusCode -eq 200) {
+ $amortResult = ($amortResp.Content | ConvertFrom-Json)
+ if ($amortResult.properties.rows) {
+ foreach ($row in $amortResult.properties.rows) {
+ $pricingModel = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+
+ if ($pricingModel -match 'Reservation') {
+ # Amortized RI cost — the actual RI spend
+ $riSavings += $cost * 0.4 # Approximate: RIs typically save ~40% vs PAYG
+ $committedAmort += $cost
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Reservation Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ elseif ($pricingModel -match 'SavingsPlan') {
+ $spSavings += $cost * 0.25 # Approximate: SPs save ~25% on average
+ $committedAmort += $cost
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Savings Plan Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ elseif ($pricingModel -match 'Spot') { $spotAmort += $cost }
+ elseif ($pricingModel) { $onDemandAmort += $cost }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ } # end per-sub fallback
+
+ # -- Step 2: AHB realized savings (per-SKU Windows license premium) ---
+ try {
+ $ahbQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where properties.licenseType == 'Windows_Server'
+| project vmSize = tostring(properties.hardwareProfile.vmSize), location
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds
+ $ahbVMs = if ($ahbResult.Data) { @($ahbResult.Data) } else { @() }
+ if ($ahbVMs.Count -gt 0) {
+ $ahbSavings = 0
+ $haveRates = Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue
+ foreach ($vm in $ahbVMs) {
+ $perVm = 50 # fallback monthly estimate per VM when live rates are unavailable
+ if ($haveRates) {
+ $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location
+ if ($rates) { $perVm = [math]::Round($rates.HourlyPremium * 730, 2) }
+ }
+ $ahbSavings += $perVm
+ }
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = 'All'
+ Category = 'Azure Hybrid Benefit (VMs)'
+ Amount = [math]::Round($ahbSavings, 2)
+ Type = 'AHB'
+ })
+ }
+ } catch {
+ Write-Warning " AHB savings query failed: $($_.Exception.Message)"
+ }
+
+ $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2)
+ $totalAnnual = [math]::Round($totalMonthly * 12, 2)
+
+ # Commitment coverage = committed eligible spend / total eligible spend.
+ # Eligible = everything except Spot (Spot cannot be covered by a commitment).
+ $eligibleBase = $committedAmort + $onDemandAmort
+ $commitmentCoverage = if ($eligibleBase -gt 0) {
+ [math]::Round(100 * $committedAmort / $eligibleBase, 1)
+ }
+ else { $null }
+
+ return [PSCustomObject]@{
+ RISavingsMonthly = [math]::Round($riSavings, 2)
+ SPSavingsMonthly = [math]::Round($spSavings, 2)
+ AHBSavingsMonthly = [math]::Round($ahbSavings, 2)
+ TotalMonthly = $totalMonthly
+ TotalAnnual = $totalAnnual
+ CommittedAmortized = [math]::Round($committedAmort, 2)
+ OnDemandAmortized = [math]::Round($onDemandAmort, 2)
+ SpotAmortized = [math]::Round($spotAmort, 2)
+ CommitmentCoveragePct = $commitmentCoverage
+ Details = @($details)
+ HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1
new file mode 100644
index 000000000..610c9777a
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1
@@ -0,0 +1,422 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-SHAREDCOSTALLOCATION.PS1
+# AZURE FINOPS MULTITOOL - Shared Hub Cost Allocation (Showback)
+###########################################################################
+# Purpose: Distribute the billed cost of SHARED hub resources (ExpressRoute
+# gateway/circuit, VPN gateway, Azure Firewall, shared bandwidth)
+# across the spoke subscriptions that consume them, and report each
+# spoke's full "solution cost" = its own resources + its allocated
+# share of the shared pool.
+#
+# Why: Shared connectivity bills entirely to the hub subscription, so
+# Cost Management cannot tell which spoke generated which gigabyte.
+# The split key (GB/TB transferred) lives in network telemetry, not
+# billing data. This tool keeps the cost math here and takes the
+# weighting vector as input - so an agent can fetch GB-per-spoke
+# from Azure Monitor / Traffic Analytics (e.g. via the Azure MCP
+# server) and hand it in, or fall back to a proxy key when no
+# flow logs exist.
+#
+# Weighting providers:
+# inline - exact GB/TB map supplied by the caller (best)
+# trafficAnalytics - exact: queries Traffic Analytics / VNet flow logs in a
+# Log Analytics workspace for measured GB per spoke
+# equal - split evenly across spokes (no measurement)
+# resourceCount - proxy: billable resource count per spoke (ARG, estimate)
+#
+# Split model (per shared pool):
+# spoke_i = (Fixed / nSpokes) + (Variable * weight_i / totalWeight)
+# where Fixed = pool * FixedRatio (split evenly - gateways cost money at
+# zero traffic) and Variable = pool * (1 - FixedRatio) (by transfer).
+#
+# Sources: Live Cost Management API (default) OR the FinOps Hub / export
+# when -HubData is injected by the caller (fast path).
+#
+# Notes:
+# - RBAC: Cost Management Reader (hub + spoke subs) + Reader (ARG).
+# - Per-spoke ExpressRoute attribution is impossible from billing alone; it
+# requires the flow-log weighting key. Without it, results are estimates
+# and are labelled as such.
+###########################################################################
+
+# -- Resolve the shared cost pool resources --------------------------------
+# From explicit resource IDs and/or a resource group, confirm the shared
+# resources exist via ARG and return their IDs + subscriptions.
+function Resolve-SharedCostPool {
+ param(
+ [string[]]$SubscriptionIds,
+ [string[]]$ResourceIds,
+ [string]$ResourceGroup
+ )
+
+ $clauses = @()
+ if ($ResourceIds -and $ResourceIds.Count -gt 0) {
+ $idList = ($ResourceIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ','
+ $clauses += "id in~ ($idList)"
+ }
+ if ($ResourceGroup) {
+ $clauses += "resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'"
+ }
+ if ($clauses.Count -eq 0) { return @() }
+
+ $where = $clauses -join ' or '
+ $query = @"
+resources
+| where $where
+| project id, name, type, resourceGroup, subscriptionId
+"@
+
+ $res = Search-AzGraphSafe -Query $query -Subscription $SubscriptionIds -First 200
+ $rows = if ($res) { @($res.Data) } else { @() }
+
+ return @($rows | ForEach-Object {
+ [PSCustomObject]@{
+ Id = [string]$_.id
+ Name = [string]$_.name
+ Type = [string]$_.type
+ ResourceGroup = [string]$_.resourceGroup
+ SubscriptionId = [string]$_.subscriptionId
+ }
+ })
+}
+
+# -- Build the spoke weighting vector --------------------------------------
+# Returns a hashtable: spokeId -> weight (double). Method decides the source.
+function Get-SpokeWeighting {
+ param(
+ [string[]]$Spokes,
+ [string]$Method,
+ [object]$Values,
+ [string[]]$SubscriptionIds,
+ [string]$WorkspaceId,
+ [int]$LookbackDays = 30,
+ [string]$Query
+ )
+
+ $weights = @{}
+ foreach ($s in $Spokes) { $weights[$s] = 0.0 }
+
+ switch ($Method) {
+ 'equal' {
+ foreach ($s in $Spokes) { $weights[$s] = 1.0 }
+ }
+ 'trafficAnalytics' {
+ if (-not $WorkspaceId) {
+ Write-Warning ' trafficAnalytics weighting needs workspaceId (the Log Analytics workspace GUID); weights left at 0.'
+ break
+ }
+ $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 }
+ # The query must return two columns: Spoke (subscription id) and GB (number).
+ $kql = if ($Query) {
+ $Query
+ }
+ else {
+ @"
+AzureNetworkAnalytics_CL
+| where TimeGenerated >= ago(${lb}d)
+| where SubType_s == 'FlowLog'
+| extend Spoke = column_ifexists('Subscription1_s', '')
+| where isnotempty(Spoke)
+| summarize GB = sum(InboundBytes_d + OutboundBytes_d) / 1e9 by Spoke
+"@
+ }
+ try {
+ $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop
+ foreach ($row in @($qr.Results)) {
+ $sid = [string]$row.Spoke
+ if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$row.GB }
+ }
+ }
+ catch {
+ Write-Warning " Traffic Analytics query failed: $($_.Exception.Message)"
+ }
+ }
+ 'resourceCount' {
+ $spokeList = ($Spokes | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ','
+ $query = @"
+resources
+| where subscriptionId in~ ($spokeList)
+| summarize c = count() by subscriptionId
+"@
+ $res = Search-AzGraphSafe -Query $query -Subscription $Spokes -First 1000
+ foreach ($r in @($res.Data)) {
+ $sid = [string]$r.subscriptionId
+ if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$r.c }
+ }
+ }
+ default {
+ # inline: read numeric values keyed by spoke id from $Values
+ if ($Values) {
+ $pairs = if ($Values -is [hashtable]) {
+ $Values.GetEnumerator()
+ }
+ else {
+ $Values.PSObject.Properties | ForEach-Object { [PSCustomObject]@{ Key = $_.Name; Value = $_.Value } }
+ }
+ foreach ($p in $pairs) {
+ $k = [string]$p.Key
+ if ($weights.ContainsKey($k)) { $weights[$k] = [double]$p.Value }
+ }
+ }
+ }
+ }
+
+ return $weights
+}
+
+# -- Read cost maps (by resource id + by subscription) ---------------------
+# One pass over the data source produces both: byResourceId (for the shared
+# pool) and bySubscriptionId (for each spoke's own cost).
+function Get-AllocationCostMaps {
+ param(
+ [string[]]$SubscriptionIds,
+ [object[]]$HubData
+ )
+
+ $byResource = @{}
+ $bySub = @{}
+ $currency = 'USD'
+ $source = 'LiveApi'
+
+ $fromHub = ($HubData -and @($HubData).Count -gt 0)
+
+ if ($fromHub) {
+ $source = 'FinOpsHub'
+ $props = $HubData[0].PSObject.Properties.Name
+ foreach ($row in $HubData) {
+ $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props)
+ $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props)
+ $sub = [string](Get-HubRowValue -Row $row -Names @('SubAccountId', 'SubscriptionId', 'x_SubscriptionId', 'SubscriptionGuid') -Props $props)
+ $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props)
+ if ($cur) { $currency = $cur }
+
+ # SubAccountId is a full path (/subscriptions/{guid}); normalize.
+ if ($sub -and $sub -match '/subscriptions/([0-9a-fA-F-]+)') { $sub = $Matches[1] }
+
+ if ($rid) {
+ if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 }
+ $byResource[$rid] += $cost
+ }
+ if ($sub) {
+ if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 }
+ $bySub[$sub] += $cost
+ }
+ }
+ }
+ else {
+ foreach ($sub in ($SubscriptionIds | Select-Object -Unique)) {
+ if (-not $sub) { continue }
+ $body = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'ResourceId' })
+ }
+ } | ConvertTo-Json -Depth 12
+
+ $path = "/subscriptions/$sub/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ $cols = @($data.properties.columns.name)
+ $iCost = [array]::IndexOf($cols, 'Cost')
+ $iRes = [array]::IndexOf($cols, 'ResourceId')
+ $iCur = [array]::IndexOf($cols, 'Currency')
+ if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 }
+ foreach ($row in @($data.properties.rows)) {
+ $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 }
+ $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' }
+ if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] }
+ $bySub[$sub] += $amount
+ if ($rid) {
+ if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 }
+ $byResource[$rid] += $amount
+ }
+ }
+ }
+ }
+ catch {
+ Write-Warning " Cost query failed for $sub : $($_.Exception.Message)"
+ }
+ }
+ }
+
+ return [PSCustomObject]@{
+ ByResource = $byResource
+ BySub = $bySub
+ Currency = $currency
+ Source = $source
+ }
+}
+
+# -- Main: allocate shared cost across spokes ------------------------------
+function Get-SharedCostAllocation {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string[]]$SharedResourceIds,
+
+ [Parameter()]
+ [string]$SharedResourceGroup,
+
+ [Parameter()]
+ [string[]]$Spokes,
+
+ [Parameter()]
+ [string]$WeightingMethod = 'inline',
+
+ [Parameter()]
+ [object]$WeightingValues,
+
+ [Parameter()]
+ [string]$WorkspaceId,
+
+ [Parameter()]
+ [int]$LookbackDays = 30,
+
+ [Parameter()]
+ [string]$WeightingQuery,
+
+ [Parameter()]
+ [double]$FixedRatio = 0.5,
+
+ [Parameter()]
+ [object[]]$HubData
+ )
+
+ if ((-not $SharedResourceIds -or $SharedResourceIds.Count -eq 0) -and -not $SharedResourceGroup) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'Provide sharedResourceIds (e.g. the ExpressRoute gateway / firewall) or a sharedResourceGroup that holds them.'
+ }
+ }
+ if (-not $Spokes -or $Spokes.Count -eq 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'Provide spokes - the subscription IDs that share the hub resources.'
+ }
+ }
+ if ($FixedRatio -lt 0) { $FixedRatio = 0 }
+ if ($FixedRatio -gt 1) { $FixedRatio = 1 }
+
+ $scanSubs = @($Subscriptions | ForEach-Object { $_.Id })
+ if (-not $scanSubs -or $scanSubs.Count -eq 0) { $scanSubs = $Spokes }
+
+ Write-Host " Resolving shared resources..." -ForegroundColor Cyan
+ $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup
+ if (-not $pool -or $pool.Count -eq 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'No shared resources matched the supplied sharedResourceIds / sharedResourceGroup in the scanned subscriptions.'
+ }
+ }
+
+ $sharedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($p in $pool) { [void]$sharedIds.Add($p.Id) }
+ $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique)
+
+ Write-Host " Reading cost (pool + spokes)..." -ForegroundColor Cyan
+ $costSubs = @($hubSubs + $Spokes | Select-Object -Unique)
+ $maps = Get-AllocationCostMaps -SubscriptionIds $costSubs -HubData $HubData
+
+ # -- Size the shared pool from billed cost ----------------------------
+ $poolTotal = 0.0
+ $poolResources = @()
+ foreach ($p in $pool) {
+ $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 }
+ $poolTotal += $c
+ $poolResources += [PSCustomObject]@{
+ Name = $p.Name
+ Type = $p.Type
+ Cost = [math]::Round($c, 2)
+ }
+ }
+
+ # -- Build the weighting vector ---------------------------------------
+ Write-Host " Building weighting ($WeightingMethod)..." -ForegroundColor Cyan
+ $weights = Get-SpokeWeighting -Spokes $Spokes -Method $WeightingMethod -Values $WeightingValues -SubscriptionIds $scanSubs -WorkspaceId $WorkspaceId -LookbackDays $LookbackDays -Query $WeightingQuery
+ $totalWeight = 0.0
+ foreach ($w in $weights.Values) { $totalWeight += [double]$w }
+
+ # -- Allocate ----------------------------------------------------------
+ $nSpokes = $Spokes.Count
+ $fixedTotal = $poolTotal * $FixedRatio
+ $varTotal = $poolTotal * (1 - $FixedRatio)
+
+ $allocations = @()
+ foreach ($s in $Spokes) {
+ $w = [double]$weights[$s]
+ $allocFixed = if ($nSpokes -gt 0) { $fixedTotal / $nSpokes } else { 0 }
+ $allocVar = if ($totalWeight -gt 0) { $varTotal * ($w / $totalWeight) } elseif ($nSpokes -gt 0) { $varTotal / $nSpokes } else { 0 }
+ $allocShared = $allocFixed + $allocVar
+ $ownCost = if ($maps.BySub.ContainsKey($s)) { [double]$maps.BySub[$s] } else { 0.0 }
+
+ $allocations += [PSCustomObject]@{
+ Spoke = $s
+ WeightUnits = [math]::Round($w, 2)
+ WeightPct = if ($totalWeight -gt 0) { [math]::Round(($w / $totalWeight) * 100, 1) } else { [math]::Round(100.0 / [math]::Max($nSpokes, 1), 1) }
+ AllocatedFixed = [math]::Round($allocFixed, 2)
+ AllocatedVar = [math]::Round($allocVar, 2)
+ AllocatedShared = [math]::Round($allocShared, 2)
+ OwnCost = [math]::Round($ownCost, 2)
+ SolutionCost = [math]::Round($ownCost + $allocShared, 2)
+ }
+ }
+ $allocations = @($allocations | Sort-Object SolutionCost -Descending)
+
+ $notes = @()
+ if ($WeightingMethod -in @('equal', 'resourceCount')) {
+ $notes += "Weighting '$WeightingMethod' is a proxy estimate, not metered transfer. Use weightingMethod=trafficAnalytics (with workspaceId) or supply inline GB/TB per spoke for an exact split."
+ }
+ elseif ($totalWeight -le 0) {
+ if ($WeightingMethod -eq 'trafficAnalytics') {
+ $notes += 'Traffic Analytics returned no GB for any spoke; variable cost was split evenly. Verify the workspace has flow logs/Traffic Analytics enabled and that the query returns Spoke + GB columns.'
+ }
+ else {
+ $notes += 'No inline weighting values resolved for any spoke; variable cost was split evenly. Provide weightingValues keyed by spoke subscription id.'
+ }
+ }
+ if ($maps.Source -eq 'LiveApi') {
+ $notes += 'Costs are live Cost Management actuals (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.'
+ }
+
+ # Ready-to-paste targets for set_cost_allocation_rule: each spoke's share of
+ # the whole pool as a whole percentage, already normalized to sum 100.00.
+ $ruleTargets = @()
+ if ($poolTotal -gt 0 -and @($allocations).Count -gt 0) {
+ $rtInput = @($allocations | ForEach-Object { @{ subscriptionId = $_.Spoke; allocatedShared = $_.AllocatedShared } })
+ $rt = ConvertTo-AllocationPercentages -Targets $rtInput -TargetDimension 'SubscriptionId'
+ if ($rt.Ok) {
+ $ruleTargets = @($rt.Values | ForEach-Object { [PSCustomObject]@{ subscriptionId = $_.name; percentage = $_.percentage } })
+ $notes += 'RuleTargets is ready to paste straight into set_cost_allocation_rule (targets); percentages already sum to 100.'
+ }
+ }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Period = 'MonthToDate'
+ Source = $maps.Source
+ Currency = $maps.Currency
+ WeightingMethod = $WeightingMethod
+ FixedRatio = $FixedRatio
+ SharedPool = [PSCustomObject]@{
+ TotalCost = [math]::Round($poolTotal, 2)
+ Resources = @($poolResources | Sort-Object Cost -Descending)
+ }
+ Allocations = $allocations
+ RuleTargets = $ruleTargets
+ Note = ($notes -join ' ')
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1
new file mode 100644
index 000000000..095ff40b1
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1
@@ -0,0 +1,131 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-STORAGETIERADVICE.PS1
+# AZURE FINOPS MULTITOOL - Storage Tier Optimization
+###########################################################################
+# Purpose: Identify storage accounts with hot-tier blob containers that
+# have not been accessed recently and would benefit from moving
+# to Cool or Archive tier to reduce costs.
+###########################################################################
+
+function Get-StorageTierAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning storage tier optimization opportunities..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Find all storage accounts on Hot default tier -----------------
+ try {
+ $query = @"
+resources
+| where type =~ 'microsoft.storage/storageaccounts'
+| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier)
+| project name, resourceGroup, subscriptionId, location,
+ kind, sku = sku.name,
+ accessTier = tostring(properties.accessTier),
+ creationTime = properties.creationTime,
+ blobCount = properties.primaryEndpoints.blob
+"@
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000
+ $hotAccounts = if ($result) { @($result.Data) } else { @() }
+ Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Storage account query failed: $($_.Exception.Message)"
+ $hotAccounts = @()
+ }
+
+ # -- 2: For each hot account, check last access metrics ---------------
+ $armBase = Get-FinOpsArmEndpoint
+ $token = (Get-AzAccessToken -ResourceUrl $armBase).Token
+ $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' }
+ $now = (Get-Date).ToUniversalTime()
+ $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ foreach ($sa in $hotAccounts) {
+ $scope = "/subscriptions/$($sa.subscriptionId)/resourceGroups/$($sa.resourceGroup)/providers/Microsoft.Storage/storageAccounts/$($sa.name)"
+ try {
+ # Query transaction count (Blob service) over last 30 days
+ $metricUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D"
+ $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
+ $metricData = ($resp.Content | ConvertFrom-Json)
+
+ $totalTx = 0
+ if ($metricData.value -and $metricData.value.Count -gt 0) {
+ foreach ($ts in $metricData.value[0].timeseries) {
+ foreach ($dp in $ts.data) {
+ if ($dp.total) { $totalTx += $dp.total }
+ }
+ }
+ }
+
+ # Also query used capacity
+ $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D"
+ $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue
+ $capacityBytes = 0
+ if ($capResp) {
+ $capData = ($capResp.Content | ConvertFrom-Json)
+ if ($capData.value -and $capData.value.Count -gt 0) {
+ foreach ($ts in $capData.value[0].timeseries) {
+ foreach ($dp in $ts.data) {
+ if ($dp.average -and $dp.average -gt $capacityBytes) { $capacityBytes = $dp.average }
+ }
+ }
+ }
+ }
+
+ $capacityGB = [math]::Round($capacityBytes / 1GB, 2)
+ $recommendation = $null
+ $estSavingsPct = 0
+
+ if ($totalTx -eq 0 -and $capacityGB -gt 0) {
+ $recommendation = 'Archive'
+ $estSavingsPct = 90
+ }
+ elseif ($totalTx -lt 100 -and $capacityGB -gt 0) {
+ $recommendation = 'Archive'
+ $estSavingsPct = 90
+ }
+ elseif ($totalTx -lt 1000 -and $capacityGB -gt 1) {
+ $recommendation = 'Cool'
+ $estSavingsPct = 50
+ }
+
+ if ($recommendation) {
+ [void]$results.Add([PSCustomObject]@{
+ StorageAccount = $sa.name
+ ResourceGroup = $sa.resourceGroup
+ SubscriptionId = $sa.subscriptionId
+ Location = $sa.location
+ CurrentTier = if ($sa.accessTier) { $sa.accessTier } else { 'Hot (default)' }
+ SKU = $sa.sku
+ CapacityGB = $capacityGB
+ Transactions30d = $totalTx
+ Recommendation = $recommendation
+ EstSavingsPct = $estSavingsPct
+ })
+ }
+ }
+ catch {
+ # Metrics not available (classic account, no blob service, etc.) — skip
+ }
+ }
+
+ Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray
+
+ [PSCustomObject]@{
+ Recommendations = @($results)
+ TotalHotAccounts = $hotAccounts.Count
+ Count = $results.Count
+ HasData = ($results.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1
new file mode 100644
index 000000000..9de999c94
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1
@@ -0,0 +1,243 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-TAGINVENTORY.PS1
+# AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant
+###########################################################################
+# Purpose: Use Azure Resource Graph to discover every tag name and value
+# in use across all subscriptions, along with resource counts
+# and resource types per tag.
+#
+# This is the "Understand" FinOps pillar - you can't allocate costs you
+# can't see, and untagged resources are invisible to chargeback.
+###########################################################################
+
+function Get-TagInventory {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # -- Query 1: Tag names, values, and counts -------------------------
+ try {
+ Write-Host " Scanning tag inventory via Resource Graph..." -ForegroundColor Cyan
+ $tagQuery = @"
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| extend tagValue = tostring(tags[tagName])
+| where isnotempty(tagName)
+| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue
+| order by tagName asc, ResourceCount desc
+"@
+
+ $allResults = @()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $tagQuery -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if (-not $result) { break }
+ $allResults += $result.Data
+ $skipToken = $result.SkipToken
+ } while ($skipToken)
+
+ } catch {
+ Write-Warning "Tag inventory query failed: $($_.Exception.Message)"
+ $allResults = @()
+ }
+
+ # -- Query 2: Untagged resource count (via REST to avoid runspace issues with single-row aggregates)
+ $untaggedCount = 0
+ try {
+ $countBody = @{
+ subscriptions = @($subIds)
+ query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()"
+ options = @{ resultFormat = 'objectArray' }
+ } | ConvertTo-Json -Depth 5
+ $countResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $countBody
+ if ($countResp.StatusCode -eq 200) {
+ $countRows = @(($countResp.Content | ConvertFrom-Json).data)
+ if ($countRows.Count -gt 0) {
+ $untaggedCount = [int]$countRows[0].UntaggedCount
+ }
+ }
+ } catch {
+ Write-Warning "Untagged resource count failed: $($_.Exception.Message)"
+ }
+
+ # -- Query 4: Untagged resource details (paginate all) ----------------
+ $untaggedResources = @()
+ try {
+ $untaggedDetailQuery = @"
+resources
+| where isnull(tags) or tags == '{}'
+| project name, type, resourceGroup, subscriptionId, location
+| order by type asc, name asc
+"@
+ $allUntagged = @()
+ $udSkipToken = $null
+ do {
+ $udResult = Search-AzGraphSafe -Query $untaggedDetailQuery -Subscription $subIds -First 1000 -SkipToken $udSkipToken
+ if (-not $udResult -or -not $udResult.Data) { break }
+ $allUntagged += $udResult.Data
+ $udSkipToken = $udResult.SkipToken
+ if ($allUntagged.Count % 2000 -eq 0) {
+ Write-Host " Loaded $($allUntagged.Count) untagged resources so far..." -ForegroundColor Gray
+ }
+ } while ($udSkipToken)
+
+ if ($allUntagged.Count -gt 0) {
+ # Map subscription IDs to names
+ $subNameMap = @{}
+ foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name }
+ $untaggedResources = @($allUntagged | ForEach-Object {
+ [PSCustomObject]@{
+ ResourceName = $_.name
+ ResourceType = $_.type
+ ResourceGroup = $_.resourceGroup
+ Subscription = if ($subNameMap.ContainsKey($_.subscriptionId)) { $subNameMap[$_.subscriptionId] } else { $_.subscriptionId }
+ Location = $_.location
+ }
+ })
+ Write-Host " Total untagged resources loaded: $($untaggedResources.Count)" -ForegroundColor Cyan
+ }
+ } catch {
+ Write-Warning "Untagged resource detail query failed: $($_.Exception.Message)"
+ }
+
+ # -- Query 3: Total resource count (via REST to avoid runspace issues with single-row aggregates)
+ $totalCount = 0
+ try {
+ $totalBody = @{
+ subscriptions = @($subIds)
+ query = "resources | summarize TotalCount = count()"
+ options = @{ resultFormat = 'objectArray' }
+ } | ConvertTo-Json -Depth 5
+ $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody
+ if ($totalResp.StatusCode -eq 200) {
+ $totalRows = @(($totalResp.Content | ConvertFrom-Json).data)
+ if ($totalRows.Count -gt 0) {
+ $totalCount = [int]$totalRows[0].TotalCount
+ }
+ }
+ } catch {
+ Write-Warning "Total resource count failed: $($_.Exception.Message)"
+ }
+
+ # Fallback: the REST count endpoint returns table-format results in some
+ # tenants (no objectArray rows), leaving the count at 0. Re-derive via the
+ # Resource Graph cmdlet path, which is reliable where the tag query works.
+ if ($totalCount -eq 0) {
+ try {
+ $tcResult = Search-AzGraphSafe -Query "resources | summarize TotalCount = count()" -Subscription $subIds -First 1
+ $tcRows = if ($tcResult) { @($tcResult.Data) } else { @() }
+ if ($tcRows.Count -gt 0 -and $null -ne $tcRows[0].TotalCount) {
+ $totalCount = [int]$tcRows[0].TotalCount
+ }
+ } catch { }
+ }
+
+ # Fallback: derive counts from detail data if REST queries failed
+ if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) {
+ $untaggedCount = $untaggedResources.Count
+ Write-Host " Using detail query count as fallback: $untaggedCount untagged" -ForegroundColor Yellow
+ }
+
+ # -- Build summary --------------------------------------------------
+ $tagNames = @{}
+ foreach ($row in $allResults) {
+ $name = $row.tagName
+ if (-not $tagNames.ContainsKey($name)) {
+ $tagNames[$name] = @{ Values = @(); TotalResources = 0 }
+ }
+ $tagNames[$name].Values += [PSCustomObject]@{
+ Value = $row.tagValue
+ ResourceCount = $row.ResourceCount
+ ResourceTypes = $row.ResourceTypes
+ }
+ $tagNames[$name].TotalResources += $row.ResourceCount
+ }
+
+ # -- Query 5: Tag locations (which subscriptions + RGs each tag is on)
+ $tagLocations = @{}
+ try {
+ $subNameMap = @{}
+ foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name }
+
+ $locQuery = @"
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| where isnotempty(tagName)
+| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup
+| order by tagName asc, ResourceCount desc
+"@
+ $locResults = @()
+ $locSkip = $null
+ do {
+ $locResult = Search-AzGraphSafe -Query $locQuery -Subscription $subIds -First 1000 -SkipToken $locSkip
+ if (-not $locResult) { break }
+ $locResults += $locResult.Data
+ $locSkip = $locResult.SkipToken
+ } while ($locSkip)
+
+ foreach ($row in $locResults) {
+ $name = $row.tagName
+ if (-not $tagLocations.ContainsKey($name)) {
+ $tagLocations[$name] = [System.Collections.Generic.List[string]]::new()
+ }
+ $subName = if ($subNameMap.ContainsKey($row.subscriptionId)) { $subNameMap[$row.subscriptionId] } else { $row.subscriptionId }
+ $loc = "$subName / $($row.resourceGroup)"
+ if ($loc -notin $tagLocations[$name]) {
+ [void]$tagLocations[$name].Add($loc)
+ }
+ }
+ } catch {
+ Write-Warning "Tag location query failed: $($_.Exception.Message)"
+ }
+
+ # Last-resort fallback: when the dedicated total query is unavailable (the
+ # REST endpoint returned no rows and the cmdlet path also came up empty),
+ # derive the total from the tagged + untagged populations so coverage is
+ # still meaningful instead of showing 0 tagged / 0 untagged.
+ if ($totalCount -eq 0) {
+ $taggedFromArg = 0
+ try {
+ $tagCountBody = @{
+ subscriptions = @($subIds)
+ query = "resources | where isnotnull(tags) and tags != '{}' | summarize TaggedCount = count()"
+ options = @{ resultFormat = 'objectArray' }
+ } | ConvertTo-Json -Depth 5
+ $tagCountResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $tagCountBody
+ if ($tagCountResp.StatusCode -eq 200) {
+ $tagCountRows = @(($tagCountResp.Content | ConvertFrom-Json).data)
+ if ($tagCountRows.Count -gt 0) { $taggedFromArg = [int]$tagCountRows[0].TaggedCount }
+ }
+ } catch { }
+ if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { $untaggedCount = $untaggedResources.Count }
+ if (($taggedFromArg + $untaggedCount) -gt 0) {
+ $totalCount = $taggedFromArg + $untaggedCount
+ }
+ }
+
+ $taggedCount = [math]::Max(0, $totalCount - $untaggedCount)
+ $tagCoverage = if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ TagNames = $tagNames
+ TagCount = $tagNames.Count
+ TagLocations = $tagLocations
+ TotalResources = $totalCount
+ TaggedCount = $taggedCount
+ UntaggedCount = $untaggedCount
+ TagCoverage = $tagCoverage
+ UntaggedResources = $untaggedResources
+ RawResults = $allResults
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1
new file mode 100644
index 000000000..c04833dfe
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1
@@ -0,0 +1,173 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-TAGRECOMMENDATIONS.PS1
+# AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices)
+###########################################################################
+# Purpose: Compare the customer's actual tags against Microsoft's
+# recommended tagging strategy from the Cloud Adoption Framework.
+#
+# Reference:
+# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging
+# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/govern/guides/standard/prescriptive-guidance#resource-tagging
+###########################################################################
+
+function Get-TagRecommendations {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [hashtable]$ExistingTags, # Keys = tag names currently in use
+
+ [hashtable]$TagLocations = @{} # Keys = tag names, Values = list of "Sub / RG" strings
+ )
+
+ # Microsoft Cloud Adoption Framework recommended tags for FinOps allocation
+ # These 7 tags map directly to CAF categories and FinOps allocation needs
+ $recommendedTags = @(
+ [PSCustomObject]@{
+ TagName = 'CostCenter'
+ Category = 'Accounting'
+ Purpose = 'Financial allocation - maps resources to internal cost centers for chargeback/showback'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 3
+ Example = 'CostCenter: CC-12345'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'BusinessUnit'
+ Category = 'Ownership'
+ Purpose = 'Org-level chargeback - enables showback/chargeback at department level'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 3
+ Example = 'BusinessUnit: Finance | Engineering | Marketing'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'ApplicationName'
+ Category = 'Functional'
+ Purpose = 'Product/service cost mapping - groups resources by the application they support'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 2
+ Example = 'ApplicationName: HRPortal | ERP | WebFrontend'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'WorkloadName'
+ Category = 'Functional'
+ Purpose = 'Workload attribution - identifies the workload a resource belongs to'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'WorkloadName: PaymentProcessing | DataPipeline'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'OpsTeam'
+ Category = 'Ownership'
+ Purpose = 'Accountability for spend - which team owns and operates the resource'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'OpsTeam: Platform-Infra | App-TeamA | SRE'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'Criticality'
+ Category = 'Classification'
+ Purpose = 'Prioritization of spend - business impact level drives optimization boundaries'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'Criticality: Mission-Critical | Business-Critical | Low'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'DataClassification'
+ Category = 'Classification'
+ Purpose = 'Compliance-driven allocation - data sensitivity determines governance requirements'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'DataClassification: Confidential | Public | Internal'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ )
+
+ # Check which recommended tags are present / missing
+ $existingNames = $ExistingTags.Keys | ForEach-Object { $_.ToLower() }
+
+ $analysis = foreach ($rec in $recommendedTags) {
+ $found = $existingNames -contains $rec.TagName.ToLower()
+
+ # Also check common variations
+ $variations = switch ($rec.TagName) {
+ 'CostCenter' { @('cost-center', 'costcenter', 'cost_center', 'cc') }
+ 'BusinessUnit' { @('bu', 'businessunit', 'business-unit', 'department', 'dept') }
+ 'ApplicationName' { @('applicationname', 'application', 'app', 'appname', 'app-name', 'workload') }
+ 'WorkloadName' { @('workloadname', 'workload', 'workload-name', 'workload_name') }
+ 'OpsTeam' { @('opsteam', 'ops-team', 'ops_team', 'operationsteam', 'team', 'owner', 'technicalowner') }
+ 'Criticality' { @('criticality', 'sla', 'tier', 'importance') }
+ 'DataClassification' { @('dataclassification', 'data-classification', 'data_classification', 'classification') }
+ default { @() }
+ }
+ $foundVariation = $existingNames | Where-Object { $_ -in $variations } | Select-Object -First 1
+
+ $status = if ($found) { 'Present' }
+ elseif ($foundVariation) { "Variation found: $foundVariation" }
+ else { 'Missing' }
+
+ # Build location string from TagLocations
+ $matchedName = if ($found) { $rec.TagName }
+ elseif ($foundVariation) { $foundVariation }
+ else { $null }
+
+ # Resolve original-case tag name from ExistingTags for accurate removal
+ $actualTagName = $null
+ if ($matchedName) {
+ $actualTagName = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1
+ if (-not $actualTagName) { $actualTagName = $matchedName }
+ }
+
+ $locationStr = ''
+ if ($matchedName) {
+ # Case-insensitive lookup in TagLocations hashtable
+ $locKey = $TagLocations.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1
+ if ($locKey -and $TagLocations[$locKey]) {
+ $locs = @($TagLocations[$locKey])
+ if ($locs.Count -le 3) {
+ $locationStr = $locs -join '; '
+ } else {
+ $locationStr = ($locs[0..2] -join '; ') + " (+$($locs.Count - 3) more)"
+ }
+ }
+ }
+
+ [PSCustomObject]@{
+ TagName = $rec.TagName
+ ActualTagName = $actualTagName
+ Status = $status
+ Priority = $rec.Priority
+ Pillar = $rec.Pillar
+ Purpose = $rec.Purpose
+ Location = $locationStr
+ Example = $rec.Example
+ Reference = $rec.Reference
+ }
+ }
+
+ $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' })
+ $missingRecommended = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Recommended' })
+ $present = @($analysis | Where-Object { $_.Status -ne 'Missing' })
+
+ return [PSCustomObject]@{
+ Analysis = $analysis
+ MissingRequired = $missingRequired
+ MissingRecommended = $missingRecommended
+ Present = $present
+ CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1
new file mode 100644
index 000000000..fadba3449
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1
@@ -0,0 +1,126 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-TENANTHIERARCHY.PS1
+# AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy
+###########################################################################
+# Purpose: Retrieve the full management group tree with subscriptions
+# nested under their parent groups.
+###########################################################################
+
+function Get-TenantHierarchy {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [int]$TimeoutSeconds = 60
+ )
+
+ try {
+ # Run in a background runspace with timeout to prevent UI freeze
+ $rs = [runspacefactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript({
+ param($tid)
+ Get-AzManagementGroup -GroupId $tid -Expand -Recurse -ErrorAction Stop
+ }).AddArgument($TenantId)
+
+ $asyncResult = $ps.BeginInvoke()
+
+ # Wait for runspace — uses DispatcherFrame when WPF is loaded, else Start-Sleep
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ if ($asyncResult.IsCompleted) {
+ $rootGroup = $ps.EndInvoke($asyncResult)
+ if ($ps.Streams.Error.Count -gt 0) {
+ throw $ps.Streams.Error[0].Exception
+ }
+ $ps.Dispose(); $rs.Close()
+
+ if ($rootGroup) {
+ $actual = if ($rootGroup -is [array]) { $rootGroup[0] } else { $rootGroup }
+ $subMap = @{}
+ Build-SubMap -Group $actual -Map ([ref]$subMap)
+ return [PSCustomObject]@{
+ RootGroup = $actual
+ SubscriptionMap = $subMap
+ }
+ }
+ }
+ else {
+ # Timed out — stop and fall through to fallback
+ $ps.Stop()
+ $ps.Dispose(); $rs.Close()
+ Write-Warning "Management group hierarchy timed out after $TimeoutSeconds seconds. Using flat subscription list."
+ }
+
+ # Fallback
+ $subs = if ($Subscriptions) { @($Subscriptions) } else {
+ @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ }
+ $fallbackRoot = [PSCustomObject]@{
+ DisplayName = "Tenant Root"
+ Name = $TenantId
+ Children = @()
+ }
+ return [PSCustomObject]@{
+ RootGroup = $fallbackRoot
+ SubscriptionMap = @{}
+ FlatSubs = $subs
+ }
+ }
+ catch {
+ # Lacking Microsoft.Management read/register access is expected for many
+ # scopes — fall back to a flat subscription list quietly instead of
+ # dumping a wall of authorization noise to the console.
+ $errMsg = $_.Exception.Message
+ if ($errMsg -match 'does not have authorization|Authorization_RequestDenied|register/action|RBACAccessDenied|Forbidden|\(403\)') {
+ Write-Verbose "Management group hierarchy not accessible (insufficient permissions); using flat subscription list."
+ }
+ else {
+ Write-Verbose "Management group hierarchy unavailable; using flat subscription list. ($errMsg)"
+ }
+
+ $subs = if ($Subscriptions) { @($Subscriptions) } else {
+ @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ }
+ $fallbackRoot = [PSCustomObject]@{
+ DisplayName = "Tenant Root"
+ Name = $TenantId
+ Children = @()
+ }
+
+ return [PSCustomObject]@{
+ RootGroup = $fallbackRoot
+ SubscriptionMap = @{}
+ FlatSubs = $subs
+ }
+ }
+}
+
+function Build-SubMap {
+ param(
+ [object]$Group,
+ [ref]$Map
+ )
+
+ if ($Group.Children) {
+ foreach ($child in $Group.Children) {
+ if ($child.Type -eq '/subscriptions') {
+ $Map.Value[$child.Name] = $Group.DisplayName
+ }
+ elseif ($child.Children) {
+ Build-SubMap -Group $child -Map $Map
+ }
+ }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1
new file mode 100644
index 000000000..806ca9f51
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1
@@ -0,0 +1,381 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-UNITECONOMICS.PS1
+# AZURE FINOPS MULTITOOL - Unit Economics ($/vCPU, $/GB)
+###########################################################################
+# Purpose: Compute FinOps unit-economics KPIs by dividing amortized cost
+# by provisioned capacity: cost per vCPU (compute) and cost per
+# GB (storage). Combines Cost Management amortized spend with
+# Azure Resource Graph capacity counts.
+###########################################################################
+# Notes:
+# - RBAC: Cost Management Reader (billing/MG scope) + Reader (ARG).
+# - vCPU and RAM are exact when the Compute SKU capability lookup succeeds
+# (authoritative vCPUs/MemoryGB per size); they fall back to a VM-size-name
+# heuristic only when the SKUs API is unavailable for a region.
+# - Storage GB combines provisioned managed-disk size (Resource Graph) and
+# Storage-account used capacity (Azure Monitor UsedCapacity metric, one
+# call per account); failures fall back to disk-only with a note.
+# - Costs are month-to-date amortized, scoped to the selected subscriptions,
+# grouped by meter category, with a per-subscription fallback when the
+# management-group scope is not accessible.
+###########################################################################
+
+# -- Helper: exact vCPU/RAM from Compute SKU capabilities -----------------
+# The Resource Graph 'resources' table does not expose vCPU/memory, so we
+# query the Compute SKUs API per region (cached by location, follows
+# nextLink) and build a vmSize -> {vCPU, MemGb} map. Returns $null when the
+# size is not found so the caller can fall back to a name heuristic.
+function Get-VmSizeCapability {
+ param(
+ [string]$SubId,
+ [string]$Location,
+ [string]$VmSize,
+ [hashtable]$Cache
+ )
+ if (-not $Location -or -not $SubId) { return $null }
+ if (-not $Cache.ContainsKey($Location)) {
+ $map = @{}
+ try {
+ $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$Location'"
+ $pages = 0
+ while ($next -and $pages -lt 6) {
+ $resp = Invoke-AzRestMethodWithRetry -Path $next -Method GET
+ if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { break }
+ $json = $resp.Content | ConvertFrom-Json
+ foreach ($s in @($json.value)) {
+ if ($s.resourceType -ne 'virtualMachines') { continue }
+ if (-not $s.name -or $map.ContainsKey($s.name)) { continue }
+ $vc = 0; $mem = 0.0
+ foreach ($c in @($s.capabilities)) {
+ if ($c.name -eq 'vCPUs') { [void][int]::TryParse([string]$c.value, [ref]$vc) }
+ elseif ($c.name -eq 'MemoryGB') { [void][double]::TryParse([string]$c.value, [ref]$mem) }
+ }
+ $map[$s.name] = @{ VCpu = $vc; MemGb = $mem }
+ }
+ $nl = $json.nextLink
+ $next = if ($nl) { ($nl -replace '^https?://[^/]+', '') } else { $null }
+ $pages++
+ }
+ }
+ catch { }
+ $Cache[$Location] = $map
+ }
+ $m = $Cache[$Location]
+ if ($m -and $m.ContainsKey($VmSize)) {
+ return [PSCustomObject]@{ VCpu = [int]$m[$VmSize].VCpu; MemGb = [double]$m[$VmSize].MemGb }
+ }
+ return $null
+}
+
+# -- Helper: accumulate amortized cost by meter category ------------------
+# Parses a Cost Management query response (grouped by MeterCategory) and adds
+# compute vs storage spend into the supplied references. Returns $true when
+# the response contained any rows (i.e. the query succeeded with data).
+function Add-MeterCosts {
+ param(
+ [string]$Content,
+ [ref]$ComputeRef,
+ [ref]$StorageRef,
+ [ref]$CurrencyRef
+ )
+ $any = $false
+ try {
+ $data = $Content | ConvertFrom-Json
+ if ($data.properties.rows) {
+ foreach ($row in $data.properties.rows) {
+ $any = $true
+ $amount = [double]$row[0]
+ $category = [string]$row[1]
+ if ($row.Count -ge 3 -and $row[2]) { $CurrencyRef.Value = [string]$row[2] }
+ switch -Wildcard ($category) {
+ 'Virtual Machines*' { $ComputeRef.Value += $amount }
+ 'Storage*' { $StorageRef.Value += $amount }
+ default { }
+ }
+ }
+ }
+ }
+ catch { }
+ return $any
+}
+
+# -- Helper: storage account used capacity (blob/file/queue/table) --------
+# Managed disks are captured separately via Resource Graph. This adds the
+# data-plane used capacity of Storage accounts via the Azure Monitor metrics
+# API (UsedCapacity, account-level, emitted once per day). One metrics call
+# per account; a failed account is skipped so it never breaks the scan.
+# Returns used GB, or $null when the account inventory could not be read.
+function Get-StorageAccountUsedGb {
+ param([string[]]$SubIds)
+
+ $accounts = @()
+ try {
+ $saQuery = @"
+resources
+| where type =~ 'microsoft.storage/storageaccounts'
+| project id
+"@
+ $result = Search-AzGraphSafe -Query $saQuery -Subscription $SubIds -First 1000
+ $accounts = if ($result) { @($result.Data) } else { @() }
+ }
+ catch {
+ Write-Warning " Storage account inventory query failed: $($_.Exception.Message)"
+ return $null
+ }
+
+ if ($accounts.Count -eq 0) { return 0.0 }
+
+ # UsedCapacity is a daily metric; a 2-day window guarantees a data point.
+ $end = (Get-Date).ToUniversalTime()
+ $start = $end.AddDays(-2)
+ $timespan = "$($start.ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))"
+
+ $totalBytes = 0.0
+ $queried = 0
+ foreach ($a in $accounts) {
+ if ($queried -ge 500) { break } # safety bound on call fan-out
+ $queried++
+ try {
+ $path = "$($a.id)/providers/Microsoft.Insights/metrics?api-version=2018-01-01&metricnames=UsedCapacity&aggregation=Average&interval=P1D×pan=$timespan"
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method GET
+ if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { continue }
+ $json = $resp.Content | ConvertFrom-Json
+ foreach ($metric in @($json.value)) {
+ foreach ($ts in @($metric.timeseries)) {
+ $points = @($ts.data) | Where-Object { $null -ne $_.average }
+ if ($points.Count -gt 0) { $totalBytes += [double]$points[-1].average }
+ }
+ }
+ }
+ catch { }
+ }
+ return [math]::Round($totalBytes / 1GB, 1)
+}
+
+function Get-UnitEconomics {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Computing unit economics (per vCPU, per GB RAM, per GB disk)..." -ForegroundColor Cyan
+
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+
+ # -- 1: Provisioned compute capacity (exact vCPU + RAM via Compute SKUs) --
+ $totalVCpu = 0
+ $totalMemGb = 0.0
+ $vmCount = 0
+ $vcpuExact = $true
+ $skuCache = @{}
+ try {
+ $vmQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| extend vmSize = tostring(properties.hardwareProfile.vmSize), loc = tostring(location), subId = tostring(subscriptionId)
+| summarize cnt = count() by vmSize, loc, subId
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ $count = [int]$r.cnt
+ $vmCount += $count
+ $caps = Get-VmSizeCapability -SubId $r.subId -Location $r.loc -VmSize $r.vmSize -Cache $skuCache
+ if ($caps -and $caps.VCpu -gt 0) {
+ $totalVCpu += ($caps.VCpu * $count)
+ $totalMemGb += ($caps.MemGb * $count)
+ }
+ else {
+ # Fall back to the leading core digit in the size name.
+ $vcpuExact = $false
+ $cores = 1
+ if ([string]$r.vmSize -match '(?i)[A-Z]+(\d+)') { $cores = [int]$matches[1] }
+ if ($cores -lt 1) { $cores = 1 }
+ $totalVCpu += ($cores * $count)
+ }
+ }
+ Write-Host " VMs: $vmCount | vCPUs: $totalVCpu | RAM: $([math]::Round($totalMemGb, 0)) GB" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " vCPU query failed: $($_.Exception.Message)"
+ }
+
+ # -- 2: Provisioned storage (managed disk GB) -------------------------
+ $diskGb = 0
+ try {
+ $diskQuery = @"
+resources
+| where type =~ 'microsoft.compute/disks'
+| summarize totalGb = sum(toint(properties.diskSizeGB))
+"@
+ $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ if ($rows.Count -gt 0 -and $null -ne $rows[0].totalGb) { $diskGb = [double]$rows[0].totalGb }
+ Write-Host " Provisioned disk: $diskGb GB" -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning " Storage capacity query failed: $($_.Exception.Message)"
+ }
+
+ # -- 2b: Storage account used capacity (blob/file/queue/table) --------
+ $blobFileGb = 0.0
+ $blobFileOk = $false
+ try {
+ $used = Get-StorageAccountUsedGb -SubIds $subIds
+ if ($null -ne $used) {
+ $blobFileGb = [double]$used
+ $blobFileOk = $true
+ Write-Host " Storage accounts used: $blobFileGb GB" -ForegroundColor Gray
+ }
+ }
+ catch {
+ Write-Warning " Storage account capacity query failed: $($_.Exception.Message)"
+ }
+
+ # Total storage denominator = managed disks + storage-account used capacity.
+ $totalGb = $diskGb + $blobFileGb
+
+ # -- 3: Amortized cost by meter category (sub-scoped, with fallback) --
+ $computeCost = 0.0
+ $storageCost = 0.0
+ $currency = 'USD'
+ $costOk = $false
+ $costScope = 'none'
+ $mgFailed = $false
+ try {
+ $mgScopeId = Resolve-CostMgId -TenantId $TenantId
+ if ($mgScopeId) {
+ $dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' })
+ }
+ # Keep the single fast MG-scope call but scope it to the selected
+ # subscriptions so the cost numerator matches the capacity denominator.
+ $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions
+ if ($subFilter) { $dataset['filter'] = $subFilter }
+ $body = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = $dataset
+ } | ConvertTo-Json -Depth 10
+
+ $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body
+
+ if ($resp -and $resp.StatusCode -in @(401, 403)) {
+ $mgFailed = $true
+ Set-MgCostScopeFailed
+ }
+ elseif ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) {
+ $costOk = $true
+ $costScope = "mg:$mgScopeId"
+ }
+ }
+ }
+ else {
+ $mgFailed = $true
+ }
+ }
+ catch {
+ Write-Warning " Amortized cost query failed: $($_.Exception.Message)"
+ $mgFailed = $true
+ }
+
+ # Per-subscription fallback when the MG scope is not accessible. This is
+ # the same pattern Get-CostData uses so unit economics is never silently $0.
+ if (-not $costOk -and $mgFailed) {
+ foreach ($sid in $subIds) {
+ try {
+ $body = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' })
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $path = "/subscriptions/$sid/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) {
+ $costOk = $true
+ $costScope = 'per-sub'
+ }
+ }
+ }
+ catch {
+ Write-Warning " Per-sub cost query failed for $sid : $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- 4: Derived KPIs --------------------------------------------------
+ $costPerVCpu = if ($totalVCpu -gt 0) { [math]::Round($computeCost / $totalVCpu, 2) } else { 0 }
+ $costPerVm = if ($vmCount -gt 0) { [math]::Round($computeCost / $vmCount, 2) } else { 0 }
+ $costPerGb = if ($totalGb -gt 0) { [math]::Round($storageCost / $totalGb, 4) } else { 0 }
+ $costPerGbRam = if ($totalMemGb -gt 0) { [math]::Round($computeCost / $totalMemGb, 2) } else { 0 }
+
+ $totalKnown = $computeCost + $storageCost
+ $computeSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $computeCost / $totalKnown, 1) } else { 0 }
+ $storageSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $storageCost / $totalKnown, 1) } else { 0 }
+
+ $hasData = $costOk -and (($totalVCpu -gt 0) -or ($totalGb -gt 0))
+
+ # -- 5: Honest diagnostics so $0 is explained, not silent -------------
+ $notes = @()
+ if (-not $costOk) {
+ $notes += 'No cost data returned - check Cost Management Reader at the management-group or subscription scope.'
+ }
+ if ($costOk -and $computeCost -eq 0 -and $vmCount -gt 0) {
+ $notes += 'VMs found but $0 compute MTD (newly created, deallocated, or fully reservation/savings-plan covered).'
+ }
+ if ($totalGb -eq 0 -and $vmCount -gt 0) {
+ $notes += 'No storage GB found - VMs may use ephemeral OS disks and no Storage accounts hold data.'
+ }
+ if (-not $blobFileOk) {
+ $notes += 'Storage-account used capacity unavailable (metrics not readable) - cost per GB reflects managed disks only.'
+ }
+ if (-not $vcpuExact) {
+ $notes += 'Some vCPU/RAM values approximated from VM size names (SKU capability lookup unavailable for a region).'
+ }
+ if ($notes.Count -eq 0) {
+ $notes += 'vCPU/RAM are exact (Compute SKU capabilities); storage GB combines managed disks and Storage-account used capacity.'
+ }
+
+ return [PSCustomObject]@{
+ HasData = $hasData
+ Currency = $currency
+ ComputeCost = [math]::Round($computeCost, 2)
+ StorageCost = [math]::Round($storageCost, 2)
+ ComputeSharePct = $computeSharePct
+ StorageSharePct = $storageSharePct
+ VmCount = $vmCount
+ TotalVCpu = $totalVCpu
+ TotalMemoryGb = [math]::Round($totalMemGb, 0)
+ DiskGb = [math]::Round($diskGb, 1)
+ BlobFileGb = [math]::Round($blobFileGb, 1)
+ TotalStorageGb = [math]::Round($totalGb, 1)
+ CostPerVCpu = $costPerVCpu
+ CostPerGbRam = $costPerGbRam
+ CostPerVm = $costPerVm
+ CostPerGb = $costPerGb
+ VCpuExact = $vcpuExact
+ BlobFileOk = $blobFileOk
+ CostScope = $costScope
+ Period = 'MonthToDate'
+ ScannedSubs = $Subscriptions.Count
+ Note = ($notes -join ' ')
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1
new file mode 100644
index 000000000..53e9e2652
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1
@@ -0,0 +1,309 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-USAGEPROPORTIONALALLOCATION.PS1
+# TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI)
+###########################################################################
+# Purpose: Split the billed cost of a shared platform across SUB-RESOURCE
+# consumers (k8s namespace, APIM product, OpenAI deployment) by a
+# usage signal pulled from telemetry - for showback/chargeback.
+# Date: Created for FinOps Multitool shared-cost allocation
+#
+# Description:
+# Native Azure cost allocation rules can only key on SubscriptionId,
+# ResourceGroupName or Tag. When the consumer you want to charge is a
+# Kubernetes namespace, an APIM product/subscription, or an Azure OpenAI
+# model deployment, there is NO billing dimension for it - so the only fair
+# split is telemetry-driven. This tool reads a usage key from a Log Analytics
+# workspace (Container Insights for AKS, workspace-based Application Insights
+# for APIM token metrics, Azure Monitor metrics for OpenAI tokens) and
+# apportions the shared pool cost accordingly.
+#
+# SHOWBACK GUARD: these consumers are NOT Azure billing dimensions, so the
+# result is Mode=Showback, RuleTargets is always empty, and it can NEVER be
+# written via set_cost_allocation_rule. It is for reporting / internal
+# chargeback only.
+#
+# ── Parameters ──────────────────────────────────────────────────
+# Preset aksNamespace | apimTokens | openAiTokens | custom
+# WorkspaceId Log Analytics workspace GUID holding the telemetry
+# SharedResourceIds Resource IDs whose billed cost forms the pool
+# SharedResourceGroup Resource group holding the shared platform
+# PoolAmount Explicit pool cost (use instead of resolving resources)
+# LookbackDays Telemetry window (default 30)
+# DimensionName Override the consumer dimension (e.g. APIM 'API ID')
+# WeightingQuery Full KQL override - must return columns Consumer, Weight
+#
+# Prerequisites:
+# - Az.OperationalInsights + read on the workspace
+# - The platform must emit the telemetry (Container Insights / App Insights
+# token metrics / AOAI diagnostic metrics)
+#
+# Usage: Get-UsageProportionalAllocation -Preset aksNamespace `
+# -SharedResourceGroup rg-aks -WorkspaceId
+###########################################################################
+
+# -- Preset registry: each preset maps a shared platform to a usage key ----
+# Query templates use {lb} (lookback days) and {dim} (dimension name). Each
+# query MUST return two columns: Consumer (string) and Weight (number).
+function Get-TelemetryPreset {
+ param([string]$Name)
+
+ $presets = @{
+ aksNamespace = @{
+ ConsumerDimension = 'KubernetesNamespace'
+ Source = 'Container Insights (Log Analytics)'
+ DefaultDimension = 'container.azm.ms/namespace'
+ Query = @'
+InsightsMetrics
+| where TimeGenerated >= ago({lb}d)
+| where Name in ('cpuUsageNanoCores', 'memoryWorkingSetBytes')
+| extend ns = tostring(parse_json(Tags)['container.azm.ms/namespace'])
+| where isnotempty(ns)
+| summarize cpuCores = avgif(Val, Name == 'cpuUsageNanoCores') / 1000000000.0, memGB = avgif(Val, Name == 'memoryWorkingSetBytes') / 1073741824.0 by ns
+| extend Weight = cpuCores + (memGB / 4.0)
+| project Consumer = ns, Weight
+'@
+ }
+ apimTokens = @{
+ ConsumerDimension = 'ApimConsumer'
+ Source = 'Application Insights (workspace-based)'
+ DefaultDimension = 'Subscription Id'
+ Query = @'
+AppMetrics
+| where TimeGenerated >= ago({lb}d)
+| where Name in ('Total Tokens', 'Tokens', 'TotalTokens', 'total_tokens')
+| extend consumer = tostring(Properties['{dim}'])
+| where isnotempty(consumer)
+| summarize Weight = sum(Sum) by Consumer = consumer
+'@
+ }
+ openAiTokens = @{
+ ConsumerDimension = 'OpenAIResource'
+ Source = 'Azure Monitor metrics (Cognitive Services)'
+ DefaultDimension = 'Resource'
+ Query = @'
+AzureMetrics
+| where TimeGenerated >= ago({lb}d)
+| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES'
+| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'ProcessedInferenceTokens')
+| summarize Weight = sum(Total) by Consumer = Resource
+'@
+ }
+ }
+
+ if ($presets.ContainsKey($Name)) { return $presets[$Name] }
+ return $null
+}
+
+# -- Generalized telemetry weighting provider ------------------------------
+function Get-TelemetryWeighting {
+ param(
+ [string]$WorkspaceId,
+ [string]$Preset = 'aksNamespace',
+ [int]$LookbackDays = 30,
+ [string]$Query,
+ [string]$DimensionName
+ )
+
+ $weights = @{}
+ $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 }
+
+ $def = Get-TelemetryPreset -Name $Preset
+ if (-not $def -and -not $Query) {
+ return [PSCustomObject]@{
+ Ok = $false
+ ConsumerDimension = 'Unknown'
+ Source = 'Unknown'
+ BillingWritable = $false
+ Weights = $weights
+ QueryUsed = $null
+ Note = "Unknown preset '$Preset'. Use aksNamespace, apimTokens, openAiTokens, or supply a weightingQuery returning Consumer, Weight."
+ }
+ }
+
+ $dimName = if ($DimensionName) { $DimensionName } elseif ($def) { $def.DefaultDimension } else { '' }
+ $kql = if ($Query) { $Query } else { $def.Query }
+ $kql = $kql.Replace('{lb}', "$lb").Replace('{dim}', $dimName)
+
+ $consumerDim = if ($def) { $def.ConsumerDimension } else { 'Custom' }
+ $source = if ($def) { $def.Source } else { 'Custom query (Log Analytics)' }
+
+ if (-not $WorkspaceId) {
+ return [PSCustomObject]@{
+ Ok = $false
+ ConsumerDimension = $consumerDim
+ Source = $source
+ BillingWritable = $false
+ Weights = $weights
+ QueryUsed = $kql
+ Note = 'workspaceId (the Log Analytics workspace GUID) is required to read telemetry.'
+ }
+ }
+
+ $note = ''
+ try {
+ $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop
+ foreach ($row in @($qr.Results)) {
+ $c = [string]$row.Consumer
+ if (-not $c) { continue }
+ $w = 0.0; [double]::TryParse([string]$row.Weight, [ref]$w) | Out-Null
+ if ($w -gt 0) { $weights[$c] = $w }
+ }
+ if ($weights.Count -eq 0) {
+ $note = 'Telemetry query returned no rows. Verify the workspace has the expected data and that the query returns Consumer + Weight columns.'
+ }
+ }
+ catch {
+ $note = "Telemetry query failed: $($_.Exception.Message)"
+ }
+
+ return [PSCustomObject]@{
+ Ok = ($weights.Count -gt 0)
+ ConsumerDimension = $consumerDim
+ Source = $source
+ BillingWritable = $false
+ Weights = $weights
+ QueryUsed = $kql
+ Note = $note
+ }
+}
+
+# -- Main: telemetry-keyed showback split of a shared pool -----------------
+function Get-UsageProportionalAllocation {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string[]]$SharedResourceIds,
+
+ [Parameter()]
+ [string]$SharedResourceGroup,
+
+ [Parameter()]
+ [double]$PoolAmount = 0,
+
+ [Parameter()]
+ [string]$Preset = 'aksNamespace',
+
+ [Parameter()]
+ [string]$WorkspaceId,
+
+ [Parameter()]
+ [int]$LookbackDays = 30,
+
+ [Parameter()]
+ [string]$DimensionName,
+
+ [Parameter()]
+ [string]$WeightingQuery,
+
+ [Parameter()]
+ [object[]]$HubData
+ )
+
+ if (-not $WorkspaceId) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'Provide workspaceId - the Log Analytics workspace GUID that holds the platform telemetry (Container Insights / App Insights).'
+ }
+ }
+ $haveResources = ($SharedResourceIds -and $SharedResourceIds.Count -gt 0) -or $SharedResourceGroup
+ if (-not $haveResources -and $PoolAmount -le 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'Provide the shared pool: sharedResourceIds / sharedResourceGroup (cost is resolved), or poolAmount (an explicit cost to split).'
+ }
+ }
+
+ $scanSubs = @($Subscriptions | ForEach-Object { $_.Id })
+
+ # -- Size the shared pool ---------------------------------------------
+ $poolTotal = 0.0
+ $poolResources = @()
+ $currency = 'USD'
+ $source = 'Explicit'
+
+ if ($haveResources) {
+ Write-Host ' Resolving shared platform resources...' -ForegroundColor Cyan
+ $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup
+ if (@($pool).Count -gt 0) {
+ $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique)
+ $maps = Get-AllocationCostMaps -SubscriptionIds $hubSubs -HubData $HubData
+ $currency = $maps.Currency
+ $source = $maps.Source
+ foreach ($p in $pool) {
+ $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 }
+ $poolTotal += $c
+ $poolResources += [PSCustomObject]@{ Name = $p.Name; Type = $p.Type; Cost = [math]::Round($c, 2) }
+ }
+ }
+ }
+
+ if ($poolTotal -le 0 -and $PoolAmount -gt 0) {
+ $poolTotal = $PoolAmount
+ $source = 'Explicit'
+ }
+
+ if ($poolTotal -le 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'The shared pool cost resolved to 0. Check the resource IDs / resource group, or pass poolAmount explicitly.'
+ }
+ }
+
+ # -- Telemetry usage key ----------------------------------------------
+ Write-Host " Reading telemetry usage ($Preset)..." -ForegroundColor Cyan
+ $tw = Get-TelemetryWeighting -WorkspaceId $WorkspaceId -Preset $Preset -LookbackDays $LookbackDays -Query $WeightingQuery -DimensionName $DimensionName
+
+ $weights = $tw.Weights
+ $totalWeight = 0.0
+ foreach ($v in $weights.Values) { $totalWeight += [double]$v }
+
+ $allocations = @()
+ $notes = @()
+ if ($totalWeight -gt 0) {
+ foreach ($key in $weights.Keys) {
+ $w = [double]$weights[$key]
+ $pct = $w / $totalWeight
+ $allocations += [PSCustomObject]@{
+ Consumer = $key
+ WeightUnits = [math]::Round($w, 4)
+ WeightPct = [math]::Round($pct * 100, 2)
+ AllocatedCost = [math]::Round($poolTotal * $pct, 2)
+ }
+ }
+ $allocations = @($allocations | Sort-Object AllocatedCost -Descending)
+ }
+ else {
+ $notes += 'No usage telemetry resolved, so the pool was not split. ' + $tw.Note
+ }
+
+ $notes += "Showback only: $($tw.ConsumerDimension) is not an Azure billing dimension, so this allocation CANNOT be written as a native cost allocation rule (set_cost_allocation_rule). Use it for internal chargeback / reporting."
+ if ($tw.Note -and $totalWeight -gt 0) { $notes += $tw.Note }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'Showback'
+ BillingWritable = $false
+ Preset = $Preset
+ ConsumerDimension = $tw.ConsumerDimension
+ Source = $tw.Source
+ CostSource = $source
+ Period = 'MonthToDate'
+ Currency = $currency
+ SharedPool = [PSCustomObject]@{
+ TotalCost = [math]::Round($poolTotal, 2)
+ Resources = @($poolResources | Sort-Object Cost -Descending)
+ }
+ Allocations = $allocations
+ RuleTargets = @()
+ Note = ($notes -join ' ')
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1
new file mode 100644
index 000000000..012b84192
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1
@@ -0,0 +1,358 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-VMCOSTBREAKDOWN.PS1
+# AZURE FINOPS MULTITOOL - Full VM Cost Decomposition
+###########################################################################
+# Purpose: Decompose a single virtual machine's total solution cost into
+# its meter components - compute, OS/data disks, network egress,
+# network infrastructure (public IP / NIC), backup/recovery,
+# security (Defender), monitoring/extension agents, and licensing
+# - instead of a single rolled-up number.
+#
+# Approach: A VM's billed cost is spread across several resource IDs (the
+# VM, its managed disks, its public IPs, and bandwidth meters).
+# We resolve the VM and its associated resources from Azure
+# Resource Graph, then read cost grouped by ResourceId + meter
+# and bucket each line item into a human-readable category.
+#
+# Sources: Live Cost Management API (default) OR the FinOps Hub / export
+# when -HubData is injected by the caller (fast path,
+# also carries consumed quantity so egress GB is exact).
+#
+# Notes:
+# - RBAC: Cost Management Reader (sub scope) + Reader (ARG).
+# - Bandwidth that Azure bills at subscription scope (no resource ID)
+# cannot be attributed to one VM; it is reported as a caveat, not folded
+# into the VM total.
+###########################################################################
+
+# -- Meter -> category classifier -----------------------------------------
+# Buckets a billed line item into a VM-solution cost category using the
+# meter category/subcategory (and resource type as a fallback for Hub rows
+# where the meter category may be sparse).
+function Get-VmMeterBucket {
+ param(
+ [string]$MeterCategory,
+ [string]$MeterSubCategory,
+ [string]$MeterName,
+ [string]$ResourceType
+ )
+
+ $cat = "$MeterCategory".Trim()
+ $sub = "$MeterSubCategory".Trim()
+ $mtr = "$MeterName".Trim()
+ $rt = "$ResourceType".Trim().ToLowerInvariant()
+ $blob = "$cat $sub $mtr"
+
+ switch -Regex ($cat) {
+ '^(Bandwidth|Inter-Region|Content Delivery Network|Routing Preference)' { return 'Network egress' }
+ '^(Virtual Network|Load Balancer|VPN Gateway|ExpressRoute|IP Addresses|NAT Gateway|Application Gateway|Azure Firewall|Azure DNS)' { return 'Network infra' }
+ '^Storage' { return 'Disk / storage' }
+ '^(Backup|Azure Site Recovery|Recovery Services)' { return 'Backup / recovery' }
+ '(Defender|Security Center)' { return 'Security (Defender)' }
+ '^(Log Analytics|Azure Monitor|Application Insights)' { return 'Monitoring / agents' }
+ '^Virtual Machines Licenses' { return 'Licensing' }
+ '^Virtual Machines' {
+ if ($blob -match '(?i)\b(Windows|SQL|RHEL|SUSE|License)\b') { return 'Licensing' }
+ return 'Compute'
+ }
+ }
+
+ # Fallback on resource type (Hub rows sometimes lack a meter category)
+ switch -Regex ($rt) {
+ 'microsoft\.compute/virtualmachines$' { return 'Compute' }
+ 'microsoft\.compute/disks$' { return 'Disk / storage' }
+ 'microsoft\.network/publicipaddresses$' { return 'Network infra' }
+ 'microsoft\.network/networkinterfaces$' { return 'Network infra' }
+ 'microsoft\.recoveryservices/' { return 'Backup / recovery' }
+ }
+
+ if ($blob -match '(?i)data transfer|egress|bandwidth') { return 'Network egress' }
+ return 'Other'
+}
+
+# -- Resolve a VM and its associated billable resources from ARG ----------
+# Returns $null when the VM cannot be located. Otherwise an object with the
+# VM identity plus a lowercased HashSet of every resource ID whose cost
+# rolls up into this VM's solution (VM, disks, NICs, public IPs).
+function Resolve-VmAssociation {
+ param(
+ [string[]]$SubscriptionIds,
+ [string]$VmName,
+ [string]$ResourceId,
+ [string]$ResourceGroup
+ )
+
+ $where = if ($ResourceId) {
+ "id =~ '$(ConvertTo-KqlLiteral $ResourceId)'"
+ }
+ elseif ($ResourceGroup) {
+ "name =~ '$(ConvertTo-KqlLiteral $VmName)' and resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'"
+ }
+ else {
+ "name =~ '$(ConvertTo-KqlLiteral $VmName)'"
+ }
+
+ $vmQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where $where
+| extend osDiskId = tostring(properties.storageProfile.osDisk.managedDisk.id)
+| project id, name, resourceGroup, location, subscriptionId,
+ vmSize = tostring(properties.hardwareProfile.vmSize),
+ osDiskId,
+ dataDisks = properties.storageProfile.dataDisks,
+ nics = properties.networkProfile.networkInterfaces
+"@
+
+ $res = Search-AzGraphSafe -Query $vmQuery -Subscription $SubscriptionIds -First 50
+ $rows = if ($res) { @($res.Data) } else { @() }
+ if ($rows.Count -eq 0) { return $null }
+
+ $ambiguous = $rows.Count -gt 1
+ $vm = $rows[0]
+
+ $assoc = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ [void]$assoc.Add([string]$vm.id)
+ if ($vm.osDiskId) { [void]$assoc.Add([string]$vm.osDiskId) }
+
+ $nicIds = @()
+ foreach ($d in @($vm.dataDisks)) {
+ $did = [string]$d.managedDisk.id
+ if ($did) { [void]$assoc.Add($did) }
+ }
+ foreach ($n in @($vm.nics)) {
+ $nid = [string]$n.id
+ if ($nid) { $nicIds += $nid; [void]$assoc.Add($nid) }
+ }
+
+ # Resolve public IPs attached to the VM's NICs
+ if ($nicIds.Count -gt 0) {
+ $nicList = ($nicIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ','
+ $pipQuery = @"
+resources
+| where type =~ 'microsoft.network/networkinterfaces'
+| where id in~ ($nicList)
+| mv-expand ipc = properties.ipConfigurations
+| extend pip = tostring(ipc.properties.publicIPAddress.id)
+| where isnotempty(pip)
+| project pip
+"@
+ try {
+ $pipRes = Search-AzGraphSafe -Query $pipQuery -Subscription $SubscriptionIds -First 100
+ foreach ($p in @($pipRes.Data)) { if ($p.pip) { [void]$assoc.Add([string]$p.pip) } }
+ }
+ catch { Write-Warning " Public IP resolution failed: $($_.Exception.Message)" }
+ }
+
+ return [PSCustomObject]@{
+ Id = [string]$vm.id
+ Name = [string]$vm.name
+ ResourceGroup = [string]$vm.resourceGroup
+ Location = [string]$vm.location
+ SubscriptionId = [string]$vm.subscriptionId
+ VmSize = [string]$vm.vmSize
+ Associated = $assoc
+ Ambiguous = $ambiguous
+ MatchCount = $rows.Count
+ }
+}
+
+function Get-VmCostBreakdown {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$VmName,
+
+ [Parameter()]
+ [string]$ResourceId,
+
+ [Parameter()]
+ [string]$ResourceGroup,
+
+ [Parameter()]
+ [object[]]$HubData
+ )
+
+ if (-not $VmName -and -not $ResourceId) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Note = 'Provide a vmName (optionally with resourceGroup) or a full resourceId to decompose.'
+ }
+ }
+
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+
+ Write-Host " Resolving VM and associated resources..." -ForegroundColor Cyan
+ $vm = Resolve-VmAssociation -SubscriptionIds $subIds -VmName $VmName -ResourceId $ResourceId -ResourceGroup $ResourceGroup
+ if (-not $vm) {
+ $target = if ($ResourceId) { $ResourceId } else { $VmName }
+ return [PSCustomObject]@{
+ HasData = $false
+ Target = $target
+ Note = "No virtual machine found matching '$target' in the scanned subscriptions."
+ }
+ }
+
+ # Bucket accumulators: category -> @{ Cost; Qty; Meters(set) }
+ $buckets = @{}
+ $addLine = {
+ param($Category, $Amount, $Qty, $MeterLabel)
+ if (-not $buckets.ContainsKey($Category)) {
+ $buckets[$Category] = @{ Cost = 0.0; Qty = 0.0; Meters = [System.Collections.Generic.HashSet[string]]::new() }
+ }
+ $buckets[$Category].Cost += [double]$Amount
+ $buckets[$Category].Qty += [double]$Qty
+ if ($MeterLabel) { [void]$buckets[$Category].Meters.Add([string]$MeterLabel) }
+ }
+
+ $currency = 'USD'
+ $source = 'LiveApi'
+ $subLevelEgress = 0.0 # bandwidth billed with no resource ID (cannot attribute)
+ $fromHub = ($HubData -and @($HubData).Count -gt 0)
+
+ if ($fromHub) {
+ # ---- FinOps Hub / export fast path (richest: carries quantity) ---
+ $source = 'FinOpsHub'
+ Write-Host " Decomposing from FinOps Hub export..." -ForegroundColor Cyan
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props)
+ if (-not $rid -or -not $vm.Associated.Contains($rid)) { continue }
+
+ $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props)
+ $qty = [double](Get-HubRowValue -Row $row -Names @('ConsumedQuantity', 'Quantity', 'UsageQuantity') -Props $props)
+ $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props)
+ if ($cur) { $currency = $cur }
+
+ $mc = [string](Get-HubRowValue -Row $row -Names @('MeterCategory', 'x_SkuMeterCategory', 'ServiceName') -Props $props)
+ $ms = [string](Get-HubRowValue -Row $row -Names @('MeterSubCategory', 'x_SkuMeterSubcategory') -Props $props)
+ $mn = [string](Get-HubRowValue -Row $row -Names @('MeterName', 'x_SkuMeterName', 'Meter') -Props $props)
+ $rt = [string](Get-HubRowValue -Row $row -Names @('ResourceType', 'x_ResourceType', 'ConsumedService') -Props $props)
+
+ $bucket = Get-VmMeterBucket -MeterCategory $mc -MeterSubCategory $ms -MeterName $mn -ResourceType $rt
+ $label = if ($ms) { "$mc / $ms" } elseif ($mc) { $mc } else { $rt }
+ & $addLine $bucket $cost $qty $label
+ }
+ }
+ else {
+ # ---- Live Cost Management API path -------------------------------
+ Write-Host " Querying cost (Cost Management, MonthToDate)..." -ForegroundColor Cyan
+ $rgFilter = @{ dimensions = @{ name = 'ResourceGroupName'; operator = 'In'; values = @($vm.ResourceGroup) } }
+ $body = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ totalQty = @{ name = 'UsageQuantity'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ResourceId' }
+ @{ type = 'Dimension'; name = 'MeterCategory' }
+ )
+ filter = $rgFilter
+ }
+ } | ConvertTo-Json -Depth 12
+
+ $path = "/subscriptions/$($vm.SubscriptionId)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ $cols = @($data.properties.columns.name)
+ $iCost = [array]::IndexOf($cols, 'Cost')
+ $iQty = [array]::IndexOf($cols, 'UsageQuantity')
+ $iRes = [array]::IndexOf($cols, 'ResourceId')
+ $iCat = [array]::IndexOf($cols, 'MeterCategory')
+ $iCur = [array]::IndexOf($cols, 'Currency')
+
+ foreach ($row in @($data.properties.rows)) {
+ $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 }
+ $qty = if ($iQty -ge 0) { [double]$row[$iQty] } else { 0 }
+ $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' }
+ $mc = if ($iCat -ge 0) { [string]$row[$iCat] } else { '' }
+ if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] }
+
+ # Bandwidth often bills with an empty resource ID at sub
+ # scope - record it as an unattributable caveat.
+ if (-not $rid) {
+ if ((Get-VmMeterBucket -MeterCategory $mc) -eq 'Network egress') { $subLevelEgress += $amount }
+ continue
+ }
+ if (-not $vm.Associated.Contains($rid)) { continue }
+
+ $bucket = Get-VmMeterBucket -MeterCategory $mc
+ & $addLine $bucket $amount $qty $mc
+ }
+ }
+ else {
+ $code = if ($resp) { $resp.StatusCode } else { 'no response' }
+ return [PSCustomObject]@{
+ HasData = $false
+ VmName = $vm.Name
+ Note = "Cost Management query failed (HTTP $code). Need Cost Management Reader on the subscription."
+ }
+ }
+ }
+ catch {
+ return [PSCustomObject]@{
+ HasData = $false
+ VmName = $vm.Name
+ Note = "Cost Management query error: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Assemble breakdown ------------------------------------------------
+ $total = 0.0
+ foreach ($b in $buckets.Values) { $total += $b.Cost }
+
+ $breakdown = @(
+ $buckets.GetEnumerator() | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Key
+ Cost = [math]::Round($_.Value.Cost, 2)
+ PctOfTotal = if ($total -gt 0) { [math]::Round(($_.Value.Cost / $total) * 100, 1) } else { 0 }
+ Quantity = [math]::Round($_.Value.Qty, 2)
+ Meters = @($_.Value.Meters)
+ }
+ } | Sort-Object Cost -Descending
+ )
+
+ $egressBucket = $buckets['Network egress']
+ $egressQty = if ($egressBucket) { [math]::Round($egressBucket.Qty, 2) } else { 0 }
+
+ $notes = @()
+ if ($vm.Ambiguous) { $notes += "$($vm.MatchCount) VMs matched '$VmName'; showing the first. Pass resourceId or resourceGroup to disambiguate." }
+ if ($subLevelEgress -gt 0) { $notes += "Excludes $([math]::Round($subLevelEgress,2)) $currency of bandwidth billed at subscription scope (no resource ID, not attributable to one VM)." }
+ if (-not $fromHub) { $notes += 'Egress quantity (GB) is exact only on the FinOps Hub path; call with dataSource=hub when an export exists.' }
+
+ return [PSCustomObject]@{
+ HasData = ($breakdown.Count -gt 0)
+ VmName = $vm.Name
+ ResourceId = $vm.Id
+ ResourceGroup = $vm.ResourceGroup
+ SubscriptionId = $vm.SubscriptionId
+ Location = $vm.Location
+ VmSize = $vm.VmSize
+ Currency = $currency
+ Period = 'MonthToDate'
+ Source = $source
+ TotalCost = [math]::Round($total, 2)
+ EgressGb = $egressQty
+ Breakdown = $breakdown
+ ResourcesIncluded = @($vm.Associated)
+ Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { 'Full VM solution cost: compute + disks + network + extensions, month-to-date.' }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1
new file mode 100644
index 000000000..d5c579505
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1
@@ -0,0 +1,259 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# INITIALIZE-SCANNER.PS1
+# AZURE FINOPS MULTITOOL - Authentication & Prerequisites
+###########################################################################
+# Purpose: Validate required Az modules, authenticate to Azure, and return
+# tenant context for the scanner to operate against.
+###########################################################################
+
+function Show-TenantPicker {
+ param([object[]]$Tenants)
+
+ Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue
+
+ $pickerXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $rdr = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($pickerXaml))
+ $dlg = [System.Windows.Markup.XamlReader]::Load($rdr)
+
+ $list = $dlg.FindName('TenantList')
+ $okBtn = $dlg.FindName('OkBtn')
+ $cancelBtn = $dlg.FindName('CancelBtn')
+
+ foreach ($t in $Tenants) {
+ $envTag = if ($t.PSObject.Properties['Environment']) { $t.Environment } else { '' }
+ $envLabel = switch ($envTag) {
+ 'AzureUSGovernment' { ' [GOV]' }
+ 'AzureCloud' { ' [Commercial]' }
+ default { '' }
+ }
+ $display = if ($t.Name -and $t.Name -ne $t.TenantId) { "$($t.Name)$envLabel ($($t.TenantId))" } else { "$($t.TenantId)$envLabel" }
+ $item = [System.Windows.Controls.ListBoxItem]::new()
+ $item.Content = $display
+ $item.Tag = "$($t.TenantId)|$envTag"
+ $list.Items.Add($item) | Out-Null
+ }
+
+ $list.Add_SelectionChanged({ $okBtn.IsEnabled = ($list.SelectedItem -ne $null) })
+ $list.Add_MouseDoubleClick({ if ($list.SelectedItem) { $dlg.DialogResult = $true; $dlg.Close() } })
+ $okBtn.Add_Click({ $dlg.DialogResult = $true; $dlg.Close() })
+ $cancelBtn.Add_Click({ $dlg.DialogResult = $false; $dlg.Close() })
+
+ if ($list.Items.Count -gt 0) { $list.SelectedIndex = 0 }
+
+ $picked = $dlg.ShowDialog()
+ if ($picked -and $list.SelectedItem) {
+ $parts = $list.SelectedItem.Tag -split '\|', 2
+ return [PSCustomObject]@{ TenantId = $parts[0]; Environment = $parts[1] }
+ }
+ return $null
+}
+
+function Initialize-Scanner {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud', 'AzureGermanCloud', '')]
+ [string]$Environment = '',
+
+ [Parameter()]
+ [System.Windows.Window]$ParentWindow,
+
+ [Parameter()]
+ [switch]$IncludeAlternateCloud
+ )
+
+ $requiredModules = @('Az.Accounts', 'Az.Resources', 'Az.ResourceGraph', 'Az.CostManagement', 'Az.Advisor', 'Az.Billing')
+ $missing = @()
+
+ foreach ($mod in $requiredModules) {
+ if (-not (Get-Module -ListAvailable -Name $mod)) {
+ $missing += $mod
+ }
+ }
+
+ if ($missing.Count -gt 0) {
+ throw "Missing required modules: $($missing -join ', '). Run: Install-Module $($missing -join ', ') -Scope CurrentUser"
+ }
+
+ # Check for existing session and auto-detect environment
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+
+ # If caller specified an environment, use it; otherwise detect from session
+ if (-not $Environment) {
+ if ($ctx) {
+ $Environment = $ctx.Environment.Name
+ Write-Host " Detected Azure environment: $Environment" -ForegroundColor Cyan
+ } else {
+ $Environment = 'AzureCloud'
+ }
+ }
+
+ # Disable the new Az login experience subscription picker (Az.Accounts 12+)
+ # so Connect-AzAccount goes straight through without console prompts
+ $env:AZURE_LOGIN_EXPERIENCE_V2 = 'Off'
+
+ # Reuse existing session if one exists in the target cloud; otherwise prompt login
+ if ($ctx -and $ctx.Account -and $ctx.Environment.Name -eq $Environment) {
+ Write-Host " Using existing Azure session: $($ctx.Account.Id) ($Environment)" -ForegroundColor Cyan
+ } else {
+ Write-Host " Authenticating to Azure ($Environment)..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ try {
+ Connect-AzAccount -Environment $Environment -ErrorAction Stop | Out-Null
+ } finally {
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ $ctx = Get-AzContext
+ }
+
+ # List all accessible tenants across environments
+ Write-Host " Loading accessible tenants..." -ForegroundColor Cyan
+ $allTenants = [System.Collections.Generic.List[object]]::new()
+ $seenTenantIds = @{}
+
+ # Get tenants from current environment
+ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ foreach ($t in $tenants) {
+ $t | Add-Member -NotePropertyName 'Environment' -NotePropertyValue $Environment -Force
+ $allTenants.Add($t)
+ $seenTenantIds[$t.TenantId] = $true
+ }
+
+ # Probe the alternate environment for additional tenants (opt-in only)
+ if ($IncludeAlternateCloud) {
+ $altEnv = if ($Environment -eq 'AzureCloud') { 'AzureUSGovernment' } else { 'AzureCloud' }
+ try {
+ Write-Host " Checking $altEnv for additional tenants..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ Connect-AzAccount -Environment $altEnv -ErrorAction Stop | Out-Null
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ $altTenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ foreach ($t in $altTenants) {
+ if (-not $seenTenantIds.ContainsKey($t.TenantId)) {
+ $t | Add-Member -NotePropertyName 'Environment' -NotePropertyValue $altEnv -Force
+ $allTenants.Add($t)
+ $seenTenantIds[$t.TenantId] = $true
+ }
+ }
+ # Switch back to original environment context
+ Connect-AzAccount -Environment $Environment -ErrorAction SilentlyContinue | Out-Null
+ } catch {
+ Write-Host " No additional tenants found in $altEnv" -ForegroundColor DarkGray
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ }
+
+ if ($allTenants.Count -eq 0) {
+ throw "No accessible tenants found."
+ }
+
+ # Always show tenant picker (even with 1 tenant, let user confirm)
+ $selection = Show-TenantPicker -Tenants $allTenants
+ if (-not $selection) {
+ throw "Tenant selection cancelled."
+ }
+
+ $selectedTenantId = $selection.TenantId
+ $selectedEnv = if ($selection.Environment) { $selection.Environment } else { $Environment }
+
+ # Switch to the selected tenant - use Set-AzContext if same cloud, full Connect if different
+ if ($selectedTenantId -ne $ctx.Tenant.Id -or $selectedEnv -ne $ctx.Environment.Name) {
+ Write-Host " Switching to tenant $selectedTenantId ($selectedEnv)..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ try {
+ Connect-AzAccount -Environment $selectedEnv -TenantId $selectedTenantId -ErrorAction Stop | Out-Null
+ } finally {
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ } else {
+ # Same tenant selected - explicitly set context to be safe
+ Write-Host " Confirming context for tenant $selectedTenantId..." -ForegroundColor Cyan
+ Set-AzContext -TenantId $selectedTenantId -ErrorAction SilentlyContinue | Out-Null
+ }
+ $ctx = Get-AzContext
+
+ # Verify the context actually landed on the right tenant
+ if ($ctx.Tenant.Id -ne $selectedTenantId) {
+ throw "Context mismatch: expected tenant $selectedTenantId but got $($ctx.Tenant.Id). Try closing all PowerShell sessions and re-running."
+ }
+
+ $tenantId = $ctx.Tenant.Id
+ $accountName = $ctx.Account.Id
+
+ # Get all accessible subscriptions
+ $subscriptions = @(Get-AzSubscription -TenantId $tenantId -ErrorAction SilentlyContinue |
+ Where-Object { $_.State -eq 'Enabled' })
+
+ # Categorize subscriptions: separate VS/MSDN/DevTest/Free subs
+ # These have spending limits, often fail Cost Management APIs, and
+ # looping through hundreds of them in a large tenant wastes hours.
+ $prodSubs = [System.Collections.Generic.List[object]]::new()
+ $skippedSubs = [System.Collections.Generic.List[object]]::new()
+
+ $skipPatterns = @(
+ 'Visual Studio', 'MSDN',
+ 'Free Trial', 'Sponsorship', 'Access to Azure Active Directory',
+ 'Azure Pass', 'BizSpark', 'Imagine', 'MPN', 'Azure in Open'
+ )
+ $skipRegex = ($skipPatterns | ForEach-Object { [regex]::Escape($_) }) -join '|'
+
+ foreach ($sub in $subscriptions) {
+ if ($sub.Name -match $skipRegex) {
+ [void]$skippedSubs.Add($sub)
+ } else {
+ [void]$prodSubs.Add($sub)
+ }
+ }
+
+ if ($skippedSubs.Count -gt 0) {
+ Write-Host " Subscriptions: $($prodSubs.Count) production, $($skippedSubs.Count) skipped (VS/MSDN/DevTest/Free)" -ForegroundColor Yellow
+ }
+
+ # Classify tenant size for adaptive scan strategies
+ $tenantSize = if ($prodSubs.Count -le 10) { 'Small' }
+ elseif ($prodSubs.Count -le 50) { 'Medium' }
+ else { 'Large' }
+ $sizeNote = switch ($tenantSize) {
+ 'Small' { "fast scan mode" }
+ 'Medium' { "standard scan mode" }
+ 'Large' { "optimized scan mode (sampling + Resource Graph)" }
+ }
+ Write-Host " Tenant size: $tenantSize ($($prodSubs.Count) subs) - $sizeNote" -ForegroundColor Cyan
+
+ return [PSCustomObject]@{
+ TenantId = $tenantId
+ AccountName = $accountName
+ Subscriptions = @($prodSubs)
+ AllSubscriptions = $subscriptions
+ SkippedSubs = @($skippedSubs)
+ Environment = $ctx.Environment.Name
+ TenantSize = $tenantSize
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1
new file mode 100644
index 000000000..811b881f9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1
@@ -0,0 +1,468 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# NEW-POWERBITEMPLATE.PS1
+# POWER BI TEMPLATE GENERATOR (GUI-FREE)
+###########################################################################
+# Purpose: Build a Power BI template (.pbit) from FinOps scan data.
+# Date: Created for FinOps Toolkit integration
+#
+# Description:
+# Headless, dependency-free generator used by the TUI and by
+# server. Given a scan-data hashtable it:
+# 1. Writes one curated CSV per scan section into the output folder.
+# 2. Clones assets\skeleton.pbit and injects a generated DataModelSchema so
+# the report's pages bind to the exported tables.
+# 3. Returns the full path to the generated FinOps-Report.pbit.
+#
+# This function performs NO UI work: no WPF, no MessageBox, no folder
+# dialogs. Errors are thrown so callers can surface them however they
+# like. Callers can invoke it directly.
+#
+# ── Parameters ──────────────────────────────────────────────────
+# ScanData Hashtable of scan results (Auth, Costs, ResourceCosts,
+# Tags, TagRecs, PolicyInv, PolicyRecs, Budgets, Orphans,
+# CostByTag, CostTrend, Commitments, AHB, Optimization,
+# Reservations, Savings).
+# OutputDir Folder to write CSVs and the .pbit into. Created if missing.
+# SkeletonPath Optional path to skeleton.pbit. Defaults to the assets
+# folder next to the module directory.
+# ScorecardRows Optional pre-computed scorecard rows.
+#
+# Prerequisites:
+# - PowerShell 7+
+# - assets\skeleton.pbit present alongside the module folder
+#
+# Usage: New-PowerBITemplate -ScanData $d -OutputDir 'C:\out\FinOps'
+###########################################################################
+
+function New-PowerBITemplate {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [hashtable]$ScanData,
+
+ [Parameter(Mandatory)]
+ [string]$OutputDir,
+
+ [string]$SkeletonPath,
+
+ [object[]]$ScorecardRows
+ )
+
+ $d = $ScanData
+ if (-not $d -or -not $d.Auth) {
+ throw 'ScanData is empty or missing Auth/Subscriptions. Run a scan first.'
+ }
+
+ if (-not (Test-Path $OutputDir)) {
+ New-Item -Path $OutputDir -ItemType Directory -Force | Out-Null
+ }
+
+ $fileCount = 0
+
+ # Helper: safe CSV export (skips empty sets)
+ $writeCsv = {
+ param([string]$Name, [object[]]$Rows)
+ if ($Rows -and $Rows.Count -gt 0) {
+ $Rows | Export-Csv -Path (Join-Path $OutputDir "$Name.csv") -NoTypeInformation -Encoding UTF8
+ $script:pbiFileCount++
+ }
+ }
+ $script:pbiFileCount = 0
+
+ # 1. Subscription Costs
+ $subRows = @()
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $c = if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id] } else { @{ Actual = 0; Forecast = 0; Currency = 'USD' } }
+ $subRows += [PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ ActualMTD = [math]::Round($c.Actual, 2)
+ Forecast = [math]::Round($c.Forecast, 2)
+ Currency = $c.Currency
+ }
+ }
+ & $writeCsv 'SubscriptionCosts' $subRows
+
+ # 2. Resource Costs
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ $rcRows = $d.ResourceCosts | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ ResourceGroup = $_.ResourceGroup
+ ResourceType = $_.ResourceType
+ ResourcePath = $_.ResourcePath
+ ActualMTD = [math]::Round($_.Actual, 2)
+ Forecast = [math]::Round($_.Forecast, 2)
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'ResourceCosts' $rcRows
+ }
+
+ # 3. Tag Inventory
+ if ($d.Tags -and $d.Tags.TagNames) {
+ $tagRows = @()
+ foreach ($tn in $d.Tags.TagNames.Keys) {
+ $info = $d.Tags.TagNames[$tn]
+ foreach ($v in $info.Values) {
+ $tagRows += [PSCustomObject]@{
+ TagName = $tn
+ TagValue = $v.Value
+ ResourceCount = $v.ResourceCount
+ }
+ }
+ }
+ & $writeCsv 'TagInventory' $tagRows
+ }
+
+ # 4. Tag Recommendations
+ if ($d.TagRecs -and $d.TagRecs.Analysis) {
+ $trRows = $d.TagRecs.Analysis | ForEach-Object {
+ [PSCustomObject]@{
+ TagName = $_.TagName
+ Status = $_.Status
+ Priority = $_.Priority
+ Pillar = $_.Pillar
+ Purpose = $_.Purpose
+ }
+ }
+ & $writeCsv 'TagRecommendations' $trRows
+ }
+
+ # 5. Policy Inventory
+ if ($d.PolicyInv -and $d.PolicyInv.Assignments) {
+ $piRows = $d.PolicyInv.Assignments | ForEach-Object {
+ [PSCustomObject]@{
+ AssignmentName = $_.AssignmentName
+ PolicyDefId = $_.PolicyDefId
+ Scope = $_.Scope
+ Effect = $_.Effect
+ EnforcementMode = $_.EnforcementMode
+ Origin = $_.Origin
+ Subscription = $_.Subscription
+ }
+ }
+ & $writeCsv 'PolicyInventory' $piRows
+ }
+
+ # 6. Policy Recommendations
+ if ($d.PolicyRecs -and $d.PolicyRecs.Analysis) {
+ $prRows = $d.PolicyRecs.Analysis | ForEach-Object {
+ [PSCustomObject]@{
+ DisplayName = $_.DisplayName
+ Category = $_.Category
+ Pillar = $_.Pillar
+ Priority = $_.Priority
+ DefaultEffect = $_.DefaultEffect
+ Purpose = $_.Purpose
+ Status = if ($d.PolicyInv -and $d.PolicyInv.Assignments -and ($_.PolicyDefId -in ($d.PolicyInv.Assignments.PolicyDefId))) { 'Assigned' } else { 'Missing' }
+ }
+ }
+ & $writeCsv 'PolicyRecommendations' $prRows
+ }
+
+ # 7. Budgets
+ if ($d.Budgets -and $d.Budgets.Budgets) {
+ $bRows = $d.Budgets.Budgets | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ SubscriptionId = $_.SubscriptionId
+ BudgetName = $_.BudgetName
+ Amount = $_.Amount
+ TimeGrain = $_.TimeGrain
+ ActualSpend = [math]::Round($_.ActualSpend, 2)
+ Forecast = [math]::Round($_.Forecast, 2)
+ PercentUsed = [math]::Round($_.PctUsed, 1)
+ Risk = $_.Risk
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'Budgets' $bRows
+ }
+
+ # 8. Orphaned Resources
+ if ($d.Orphans -and $d.Orphans.Orphans) {
+ $oRows = $d.Orphans.Orphans | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Category
+ ResourceName = $_.ResourceName
+ ResourceGroup = $_.ResourceGroup
+ SubscriptionId = $_.SubscriptionId
+ Location = $_.Location
+ Detail = $_.Detail
+ Impact = $_.Impact
+ }
+ }
+ & $writeCsv 'OrphanedResources' $oRows
+ }
+
+ # 9. Cost by Tag
+ if ($d.CostByTag -and $d.CostByTag.CostByTag) {
+ $ctRows = @()
+ foreach ($tagKey in $d.CostByTag.CostByTag.Keys) {
+ foreach ($entry in $d.CostByTag.CostByTag[$tagKey]) {
+ $ctRows += [PSCustomObject]@{
+ TagName = $tagKey
+ TagValue = $entry.TagValue
+ Cost = [math]::Round($entry.Cost, 2)
+ Currency = $entry.Currency
+ }
+ }
+ }
+ & $writeCsv 'CostByTag' $ctRows
+ }
+
+ # 10. Cost Trend
+ if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months) {
+ $tRows = $d.CostTrend.Months | ForEach-Object {
+ [PSCustomObject]@{
+ Month = $_.Month
+ Cost = [math]::Round($_.Cost, 2)
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'CostTrend' $tRows
+ }
+
+ # 11. Commitment Utilization
+ if ($d.Commitments -and $d.Commitments.HasData) {
+ $cmRows = @()
+ if ($d.Commitments.Reservations) {
+ $cmRows += $d.Commitments.Reservations | ForEach-Object {
+ [PSCustomObject]@{
+ Type = 'Reservation'
+ Id = $_.ReservationId
+ SkuName = $_.SkuName
+ AvgUtilization = [math]::Round($_.AvgUtilization, 1)
+ MinUtilization = [math]::Round($_.MinUtilization, 1)
+ MaxUtilization = [math]::Round($_.MaxUtilization, 1)
+ ReservedHours = $_.ReservedHours
+ UsedHours = $_.UsedHours
+ }
+ }
+ }
+ if ($d.Commitments.SavingsPlans) {
+ $cmRows += $d.Commitments.SavingsPlans | ForEach-Object {
+ [PSCustomObject]@{
+ Type = 'SavingsPlan'
+ Id = $_.BenefitId
+ SkuName = ''
+ AvgUtilization = [math]::Round($_.AvgUtilization, 1)
+ MinUtilization = 0
+ MaxUtilization = 0
+ ReservedHours = 0
+ UsedHours = 0
+ }
+ }
+ }
+ & $writeCsv 'CommitmentUtilization' $cmRows
+ }
+
+ # 12. AHB Opportunities
+ if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) {
+ $ahbRows = @()
+ foreach ($prop in @('WindowsVMs', 'SQLVMs', 'SQLDatabases')) {
+ if ($d.AHB.$prop) {
+ $ahbRows += $d.AHB.$prop | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $prop
+ ResourceName = $_.name
+ ResourceGroup = $_.resourceGroup
+ SubscriptionId = $_.subscriptionId
+ Location = $_.location
+ }
+ }
+ }
+ }
+ & $writeCsv 'AHBOpportunities' $ahbRows
+ }
+
+ # 13. Optimization / Advisor Recommendations
+ if ($d.Optimization -and $d.Optimization.Recommendations) {
+ $optRows = $d.Optimization.Recommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Category = $_.Category
+ Impact = $_.Impact
+ Problem = $_.Problem
+ Solution = $_.Solution
+ ResourceType = $_.ResourceType
+ ResourceName = $_.ResourceName
+ AnnualSavings = if ($_.AnnualSavings) { [math]::Round($_.AnnualSavings, 2) } else { '' }
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'OptimizationAdvice' $optRows
+ }
+
+ # 14. Reservation Recommendations
+ if ($d.Reservations) {
+ $resRows = @()
+ if ($d.Reservations.AdvisorRecommendations) {
+ $resRows += $d.Reservations.AdvisorRecommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Source = 'Advisor'
+ Subscription = $_.Subscription
+ ResourceType = $_.ResourceType
+ Impact = $_.Impact
+ Problem = $_.Problem
+ AnnualSavings = if ($_.AnnualSavings) { [math]::Round($_.AnnualSavings, 2) } else { '' }
+ Term = $_.Term
+ Currency = $_.Currency
+ }
+ }
+ }
+ if ($d.Reservations.ReservationRecommendations) {
+ $resRows += $d.Reservations.ReservationRecommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Source = 'ReservationAPI'
+ Subscription = ''
+ ResourceType = $_.ResourceType
+ Impact = ''
+ Problem = "Buy $($_.RecommendedQty)x $($_.SKU) ($($_.Term))"
+ AnnualSavings = if ($_.NetSavings) { [math]::Round($_.NetSavings, 2) } else { '' }
+ Term = $_.Term
+ Currency = ''
+ }
+ }
+ }
+ & $writeCsv 'ReservationAdvice' $resRows
+ }
+
+ # 15. Savings Realized
+ if ($d.Savings -and $d.Savings.HasData -and $d.Savings.Details) {
+ $sRows = $d.Savings.Details | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Category = $_.Category
+ Amount = [math]::Round($_.Amount, 2)
+ Type = $_.Type
+ }
+ }
+ & $writeCsv 'SavingsRealized' $sRows
+ }
+
+ # 16. Scorecard (caller-supplied, GUI only)
+ if ($ScorecardRows -and $ScorecardRows.Count -gt 0) {
+ & $writeCsv 'Scorecard' @($ScorecardRows)
+ }
+
+ # ================================================================
+ # Generate Power BI Template (.pbit)
+ # ================================================================
+ Add-Type -AssemblyName System.IO.Compression
+
+ $csvFiles = Get-ChildItem -Path $OutputDir -Filter '*.csv'
+ if ($csvFiles.Count -eq 0) {
+ throw 'No data available to export. The scan produced no rows.'
+ }
+ $numericCols = @('ActualMTD', 'Forecast', 'Cost', 'Amount', 'ActualSpend', 'PercentUsed', 'AnnualSavings', 'AvgUtilization', 'MinUtilization', 'MaxUtilization', 'ReservedHours', 'UsedHours', 'ResourceCount')
+ $exportDirEscaped = $OutputDir -replace '\\', '\\\\'
+
+ # Build DataModelSchema JSON manually to avoid ConvertTo-Json issues
+ $sb = [System.Text.StringBuilder]::new(8192)
+ [void]$sb.Append('{"name":"Model","compatibilityLevel":1550,"model":{"culture":"en-US","dataAccessOptions":{"legacyRedirects":true,"returnErrorValuesAsNull":true},"defaultPowerBIDataSourceVersion":"powerBI_V3","sourceQueryCulture":"en-US","tables":[')
+
+ # CsvFolderPath parameter table
+ $paramGuid = [guid]::NewGuid().ToString()
+ $paramColGuid = [guid]::NewGuid().ToString()
+ [void]$sb.Append('{"name":"CsvFolderPath","lineageTag":"' + $paramGuid + '","columns":[{"name":"CsvFolderPath","dataType":"string","isHidden":true,"sourceColumn":"CsvFolderPath","lineageTag":"' + $paramColGuid + '"}],"partitions":[{"name":"CsvFolderPath","mode":"import","source":{"type":"m","expression":["\"' + $exportDirEscaped + '\" meta [IsParameterQuery=true, Type=\"Text\", IsParameterQueryRequired=true]"]}}],"annotations":[{"name":"PBI_ResultType","value":"Text"},{"name":"PBI_NavigationStepName","value":"Navigation"}]}')
+
+ # Data tables from CSVs
+ foreach ($csv in $csvFiles) {
+ $tblName = [System.IO.Path]::GetFileNameWithoutExtension($csv.Name)
+ $headerLine = Get-Content $csv.FullName -First 1
+ $headers = ($headerLine -replace '"', '') -split ','
+ $tblGuid = [guid]::NewGuid().ToString()
+
+ [void]$sb.Append(',{"name":"' + $tblName + '","lineageTag":"' + $tblGuid + '","columns":[')
+ $colFragments = @()
+ $typeCasts = @()
+ foreach ($h in $headers) {
+ $cGuid = [guid]::NewGuid().ToString()
+ $isNum = $h -in $numericCols
+ $dt = if ($isNum) { 'double' } else { 'string' }
+ $sum = if ($isNum) { 'sum' } else { 'none' }
+ $colFragments += '{"name":"' + $h + '","dataType":"' + $dt + '","sourceColumn":"' + $h + '","summarizeBy":"' + $sum + '","lineageTag":"' + $cGuid + '"}'
+ if ($isNum) { $typeCasts += '{\"' + $h + '\", type number}' }
+ }
+ [void]$sb.Append($colFragments -join ',')
+ [void]$sb.Append('],')
+
+ # Partition with M expression
+ $mExpr = @()
+ $mExpr += '"let"'
+ if ($typeCasts.Count -gt 0) {
+ $mExpr += '" Source = Csv.Document(File.Contents(CsvFolderPath & \"\\\\' + $tblName + '.csv\"), [Delimiter=\",\", Encoding=65001, QuoteStyle=QuoteStyle.Csv]),"'
+ $mExpr += '" Headers = Table.PromoteHeaders(Source, [PromoteAllScalars=true]),"'
+ $castStr = $typeCasts -join ', '
+ $mExpr += '" Typed = Table.TransformColumnTypes(Headers, {' + $castStr + '})"'
+ $mExpr += '"in"'
+ $mExpr += '" Typed"'
+ }
+ else {
+ $mExpr += '" Source = Csv.Document(File.Contents(CsvFolderPath & \"\\\\' + $tblName + '.csv\"), [Delimiter=\",\", Encoding=65001, QuoteStyle=QuoteStyle.Csv]),"'
+ $mExpr += '" Headers = Table.PromoteHeaders(Source, [PromoteAllScalars=true])"'
+ $mExpr += '"in"'
+ $mExpr += '" Headers"'
+ }
+
+ [void]$sb.Append('"partitions":[{"name":"' + $tblName + '","mode":"import","source":{"type":"m","expression":[' + ($mExpr -join ',') + ']}}]}')
+ }
+
+ [void]$sb.Append('],') # end tables
+
+ # Relationships
+ $tblNames = @('CsvFolderPath') + @($csvFiles | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) })
+ $relFragments = @()
+ $subIdTables = @('Budgets', 'OrphanedResources', 'AHBOpportunities')
+ foreach ($ft in $subIdTables) {
+ if ($ft -in $tblNames -and 'SubscriptionCosts' -in $tblNames) {
+ $rGuid = [guid]::NewGuid().ToString()
+ $relFragments += '{"name":"' + $rGuid + '","fromTable":"' + $ft + '","fromColumn":"SubscriptionId","toTable":"SubscriptionCosts","toColumn":"SubscriptionId"}'
+ }
+ }
+ [void]$sb.Append('"relationships":[' + ($relFragments -join ',') + '],')
+ [void]$sb.Append('"annotations":[{"name":"PBI_QueryGroup","value":"{}"},{"name":"PBIDesktopVersion","value":"2.138.0.0"}]}}')
+
+ $modelJson = $sb.ToString()
+
+ # Resolve skeleton .pbit (default: gui folder next to this module dir)
+ if (-not $SkeletonPath) {
+ $SkeletonPath = Join-Path (Join-Path (Split-Path $PSScriptRoot -Parent) 'assets') 'skeleton.pbit'
+ }
+ if (-not (Test-Path $SkeletonPath)) {
+ throw "skeleton.pbit not found at: $SkeletonPath"
+ }
+
+ $pbitPath = Join-Path $OutputDir 'FinOps-Report.pbit'
+ Copy-Item $SkeletonPath $pbitPath -Force
+ if ((Get-Item $pbitPath).Length -lt 1000) {
+ throw "skeleton.pbit copy failed - file too small. Source: $SkeletonPath"
+ }
+
+ $unicodeNoBom = [System.Text.UnicodeEncoding]::new($false, $false)
+ $zip = [System.IO.Compression.ZipFile]::Open($pbitPath, [System.IO.Compression.ZipArchiveMode]::Update)
+ try {
+ $dmEntry = $zip.Entries | Where-Object { $_.FullName -eq 'DataModelSchema' }
+ if (-not $dmEntry) { throw 'DataModelSchema entry not found in skeleton' }
+ $dmName = $dmEntry.FullName
+ $dmEntry.Delete()
+ $newDm = $zip.CreateEntry($dmName)
+ $sw = [System.IO.StreamWriter]::new($newDm.Open(), $unicodeNoBom)
+ $sw.Write($modelJson)
+ $sw.Close()
+ }
+ finally {
+ $zip.Dispose()
+ }
+
+ return [PSCustomObject]@{
+ PbitPath = $pbitPath
+ CsvCount = $csvFiles.Count
+ OutputDir = $OutputDir
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1
new file mode 100644
index 000000000..53220bd13
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1
@@ -0,0 +1,288 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# REMOVE-ORPHANEDRESOURCE.PS1
+# AZURE FINOPS MULTITOOL - Safe Deletion of Orphaned Resources
+###########################################################################
+# Purpose: Delete a single orphaned Azure resource (unattached managed
+# disk, dangling public IP, unattached NIC, or stale snapshot)
+# discovered by scan_orphaned_resources.
+#
+# Description:
+# Safe-by-default remediation, matching the Set-CostAllocationRule pattern:
+# 1. Validates the resource id and refuses any type not on the allow-list
+# (only orphan-eligible types can EVER be deleted by this tool).
+# 2. Re-reads the resource and re-verifies it is genuinely orphaned at
+# execution time (defense in depth - never delete on a stale scan).
+# 3. DRY-RUN by default: returns the exact DELETE call + orphan evidence
+# and mutates nothing. Only deletes when -Apply is explicitly passed.
+#
+# ── Parameters ──────────────────────────────────────────────────────
+# ResourceId Full ARM resource ID of the orphan to delete
+# Apply Safety gate. Omitted = dry-run preview (no write).
+# Present = perform the DELETE after user approval.
+#
+# Prerequisites:
+# - Contributor (or a delete-capable role) on the target resource scope
+#
+# Usage: Remove-OrphanedResource -ResourceId [-Apply]
+###########################################################################
+
+function Remove-OrphanedResource {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')]
+ param(
+ [Parameter(Mandatory)]
+ [string]$ResourceId,
+
+ [Parameter()]
+ [switch]$Apply,
+
+ [Parameter()]
+ [string]$ConfirmationToken
+ )
+
+ # -----------------------------------------------------------------
+ # Allow-list: ONLY these types may ever be deleted by this tool.
+ # inUseProps = the resource 'properties' fields that, when populated,
+ # mean the resource is STILL IN USE (so we must refuse to delete).
+ # -----------------------------------------------------------------
+ $allowList = @{
+ 'Microsoft.Compute/disks' = @{ api = '2023-04-02'; label = 'Managed disk'; inUseProps = @('managedBy', 'diskState'); kind = 'attachment' }
+ 'Microsoft.Network/publicIPAddresses' = @{ api = '2023-09-01'; label = 'Public IP address'; inUseProps = @('ipConfiguration', 'natGateway'); kind = 'attachment' }
+ 'Microsoft.Network/networkInterfaces' = @{ api = '2023-09-01'; label = 'Network interface'; inUseProps = @('virtualMachine', 'privateEndpoint'); kind = 'attachment' }
+ 'Microsoft.Compute/snapshots' = @{ api = '2023-04-02'; label = 'Disk snapshot'; inUseProps = @(); kind = 'backup' }
+ }
+
+ # ---- Validate the resource id ----
+ if ($ResourceId -notmatch '^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[^/]+/providers/') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "Invalid resourceId. Expected a full ARM resource ID like /subscriptions/{guid}/resourceGroups/{rg}/providers/{ns}/{type}/{name}."
+ }
+ }
+
+ if ($ResourceId -notmatch '/providers/(?Microsoft\.[^/]+)/(?[^/]+)/(?[^/]+)$') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "Could not parse a top-level resource type from resourceId. This tool only deletes top-level orphaned resources (disks, public IPs, NICs, snapshots)."
+ }
+ }
+ $fullType = "$($Matches.ns)/$($Matches.type)"
+ $resName = $Matches.name
+
+ if (-not $allowList.ContainsKey($fullType)) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "Refusing to delete '$fullType'. This tool only deletes orphan-eligible types: $($allowList.Keys -join ', '). Use the appropriate scan/remediation path for other resources."
+ ResourceId = $ResourceId
+ ResourceType = $fullType
+ }
+ }
+
+ $cfg = $allowList[$fullType]
+ $apiVersion = $cfg.api
+ $path = "$ResourceId`?api-version=$apiVersion"
+
+ # ---- Re-read the resource (confirm it exists + is still orphaned) ----
+ $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET'
+ $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 }
+
+ if ($getStatus -eq 404) {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = if ($Apply) { 'Apply' } else { 'DryRun' }
+ Applied = $false
+ ResourceId = $ResourceId
+ ResourceType = $fullType
+ Note = 'Resource not found (already deleted or never existed). Nothing to do.'
+ }
+ }
+ if ($getStatus -lt 200 -or $getStatus -ge 300) {
+ $gErr = $null
+ if ($getResp -and $getResp.Content) {
+ try { $gErr = ($getResp.Content | ConvertFrom-Json -ErrorAction Stop).error.message } catch { $gErr = $getResp.Content }
+ }
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "Could not read the resource before deleting (HTTP $getStatus). $gErr"
+ ResourceId = $ResourceId
+ ResourceType = $fullType
+ }
+ }
+
+ $resObj = $null
+ try { $resObj = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {}
+
+ # Without a parsed body the orphan check below cannot evaluate anything and
+ # would pass by default, so refuse rather than delete on unverified state.
+ if (-not $resObj) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Mode = 'Blocked'
+ Applied = $false
+ Error = "Refusing to delete: the response for '$resName' could not be parsed, so it cannot be confirmed as orphaned."
+ ResourceId = $ResourceId
+ ResourceType = $fullType
+ }
+ }
+
+ $props = $resObj.properties
+ $location = $resObj.location
+
+ # ---- Defense in depth: verify the resource is genuinely orphaned ----
+ $inUseBy = @()
+ foreach ($p in $cfg.inUseProps) {
+ if ($p -eq 'diskState') {
+ # A disk is in use unless its diskState is Unattached/Reserved.
+ if ($props -and $props.diskState -and $props.diskState -notin @('Unattached', 'Reserved')) {
+ $inUseBy += "diskState=$($props.diskState)"
+ }
+ continue
+ }
+ $val = if ($props) { $props.$p } else { $null }
+ $populated = $false
+ if ($null -ne $val) {
+ if ($val -is [string]) { $populated = -not [string]::IsNullOrWhiteSpace($val) }
+ elseif ($val.PSObject -and $val.PSObject.Properties.Name -contains 'id') { $populated = [bool]$val.id }
+ else { $populated = $true }
+ }
+ if ($populated) { $inUseBy += $p }
+ }
+
+ if ($inUseBy.Count -gt 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Mode = 'Blocked'
+ Applied = $false
+ Error = "Refusing to delete: '$resName' appears to be IN USE ($($inUseBy -join ', ')). It is not orphaned. Re-run scan_orphaned_resources to refresh, or detach it first."
+ ResourceId = $ResourceId
+ ResourceType = $fullType
+ Location = $location
+ InUseBy = $inUseBy
+ }
+ }
+
+ # ---- Build human-readable evidence for the preview ----
+ $evidence = [ordered]@{}
+ switch ($fullType) {
+ 'Microsoft.Compute/disks' {
+ $evidence['diskState'] = $props.diskState
+ $evidence['sizeGB'] = $props.diskSizeGB
+ $evidence['sku'] = if ($resObj.sku) { $resObj.sku.name } else { $null }
+ }
+ 'Microsoft.Network/publicIPAddresses' {
+ $evidence['ipAddress'] = $props.ipAddress
+ $evidence['allocationMethod'] = $props.publicIPAllocationMethod
+ $evidence['sku'] = if ($resObj.sku) { $resObj.sku.name } else { $null }
+ }
+ 'Microsoft.Network/networkInterfaces' {
+ $evidence['attachedVM'] = 'none'
+ }
+ 'Microsoft.Compute/snapshots' {
+ $evidence['sizeGB'] = $props.diskSizeGB
+ $evidence['timeCreated'] = $props.timeCreated
+ }
+ }
+
+ $irreversible = if ($cfg.kind -eq 'backup') {
+ 'This is a point-in-time backup. Deletion is IRREVERSIBLE and you lose the restore point.'
+ }
+ else {
+ 'Deletion is IRREVERSIBLE. The orphaned resource and any data on it are permanently removed.'
+ }
+
+ # ---- Route through the configurable write-safety gate ----
+ $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null }
+ $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null }
+ $tagHash = @{}
+ if ($resObj -and $resObj.tags) {
+ foreach ($t in $resObj.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value }
+ }
+ $estImpact = 0
+ if ($evidence['estMonthlySavings']) { $estImpact = [double]$evidence['estMonthlySavings'] }
+
+ $decision = Resolve-WriteDecision -ToolName 'remediate_delete_orphaned_resource' -Operation 'Delete' `
+ -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash `
+ -EstimatedMonthlyImpact $estImpact -Reversible $false -Apply:$Apply -ConfirmationToken $ConfirmationToken
+
+ # ---- BLOCKED by mode/guardrails/enforcement ----
+ if ($decision.Decision -eq 'Blocked') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Mode = 'Blocked'
+ Applied = $false
+ Error = $decision.Reason
+ GuardrailViolations = @($decision.GuardrailViolations)
+ WriteMode = $decision.Mode
+ ResourceId = $ResourceId
+ ResourceName = $resName
+ ResourceType = $fullType
+ }
+ }
+
+ # ---- DRY RUN (default): preview the DELETE, mutate nothing ----
+ if ($decision.Decision -eq 'Preview') {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'DryRun'
+ Applied = $false
+ WriteMode = $decision.Mode
+ Warning = "PREVIEW ONLY - nothing was deleted. $irreversible $($decision.Reason)"
+ Method = 'DELETE'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ ResourceId = $ResourceId
+ ResourceName = $resName
+ ResourceType = $fullType
+ TypeLabel = $cfg.label
+ Location = $location
+ OrphanEvidence = [PSCustomObject]$evidence
+ ConfirmationToken = $decision.ConfirmationToken
+ RequiresToken = $decision.RequiresToken
+ NextStep = if ($decision.RequiresToken) {
+ 'Enforced mode: re-run remediate_delete_orphaned_resource with apply=true AND confirmationToken=, after user confirmation.'
+ }
+ else {
+ 'Re-run remediate_delete_orphaned_resource with apply=true (after user confirmation) to delete this resource.'
+ }
+ }
+ }
+
+ # ---- APPLY (decision = Proceed): perform the DELETE ----
+ Write-Host " Deleting orphaned $($cfg.label) '$resName'..." -ForegroundColor Yellow
+ $delResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'DELETE'
+ $delStatus = if ($delResp) { [int]$delResp.StatusCode } else { 0 }
+ # 200 OK, 202 Accepted (async), 204 No Content all indicate success.
+ $ok = $delStatus -in @(200, 202, 204)
+
+ $errMsg = $null
+ if (-not $ok) {
+ $errMsg = "DELETE returned $delStatus."
+ if ($delResp -and $delResp.Content) {
+ try {
+ $eb = ($delResp.Content | ConvertFrom-Json -ErrorAction Stop)
+ if ($eb.error -and $eb.error.message) { $errMsg += " $($eb.error.message)" }
+ }
+ catch {}
+ }
+ }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'Apply'
+ Applied = $ok
+ WriteMode = $decision.Mode
+ StatusCode = $delStatus
+ Warning = if ($ok) { 'Resource deleted. This is irreversible.' } else { $null }
+ Error = $errMsg
+ Method = 'DELETE'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ ResourceId = $ResourceId
+ ResourceName = $resName
+ ResourceType = $fullType
+ TypeLabel = $cfg.label
+ Location = $location
+ Async = ($delStatus -eq 202)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1
new file mode 100644
index 000000000..8d2560961
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1
@@ -0,0 +1,319 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# SET-COSTALLOCATIONRULE.PS1
+# WRITE-BACK: NATIVE AZURE COST ALLOCATION RULE (CHARGEBACK)
+###########################################################################
+# Purpose: Push transfer-weighted percentages into a native Azure Cost
+# Management cost allocation rule so chargeback reflects the split.
+# Date: Created for FinOps Multitool shared-cost allocation
+#
+# Description:
+# Creates or updates a Cost Management cost allocation rule via the ARM REST
+# API (api-version 2025-03-01). The rule reallocates the cost of a shared
+# source (a hub resource group or subscription) to spoke subscriptions by
+# fixed percentage. Intended to be fed from scan_allocate_shared_cost output.
+#
+# SAFETY: This MUTATES chargeback. It is DRY-RUN by default. Nothing is
+# written to Azure unless -Apply is passed. The dry-run returns the exact
+# PUT URI and request body so a human can review before it is applied. The
+# agent must present the preview and obtain explicit confirmation first.
+#
+# ── Parameters ──────────────────────────────────────────────────
+# BillingAccountId EA enrollment id or MCA billing account id (rule scope)
+# RuleName Rule name ([A-Za-z0-9_-]+, max 260 chars)
+# SourceResourceGroup Shared-cost source resource group name(s) (Dimension)
+# SourceSubscriptionId Shared-cost source subscription id(s) (alt to RG)
+# Targets Spoke targets: array of {subscriptionId, percentage}
+# or {spoke, allocatedShared} (percent derived)
+# TargetDimension SubscriptionId (default) or ResourceGroupName
+# Status Active (default) or NotActive
+# Description Free-text rule description
+# Apply Switch. Without it, returns a preview and writes nothing
+#
+# Prerequisites:
+# - Cost Management Contributor on the billing account / enrollment
+# - EA, MCA-E, or MCA-online billing account (cost allocation supported)
+#
+# Usage: Set-CostAllocationRule -BillingAccountId 100 -RuleName hub-split `
+# -SourceResourceGroup rg-hub -Targets $alloc -Apply
+###########################################################################
+
+function Get-AllocTargetProp {
+ param(
+ [object]$Item,
+ [string[]]$Names
+ )
+ foreach ($n in $Names) {
+ if ($Item -is [hashtable]) {
+ if ($Item.ContainsKey($n) -and $null -ne $Item[$n] -and "$($Item[$n])".Trim().Length -gt 0) {
+ return $Item[$n]
+ }
+ }
+ elseif ($null -ne $Item) {
+ $p = $Item.PSObject.Properties[$n]
+ if ($p -and $null -ne $p.Value -and "$($p.Value)".Trim().Length -gt 0) {
+ return $p.Value
+ }
+ }
+ }
+ return $null
+}
+
+function ConvertTo-AllocationPercentages {
+ # Normalizes a target list to whole percentages that sum to EXACTLY 100.00.
+ param(
+ [object[]]$Targets,
+ [string]$TargetDimension
+ )
+
+ $raw = @()
+ $anyPct = $false
+ foreach ($t in $Targets) {
+ $name = Get-AllocTargetProp -Item $t -Names @('subscriptionId', 'SubscriptionId', 'spoke', 'Spoke', 'name', 'Name')
+ if (-not $name) { continue }
+ $pct = Get-AllocTargetProp -Item $t -Names @('percentage', 'Percentage', 'pct', 'Pct')
+ $share = Get-AllocTargetProp -Item $t -Names @('allocatedShared', 'AllocatedShared', 'weight', 'Weight', 'value', 'Value')
+ if ($null -ne $pct) { $anyPct = $true; $val = [double]$pct }
+ elseif ($null -ne $share) { $val = [double]$share }
+ else { $val = 0.0 }
+ $raw += [PSCustomObject]@{ Name = [string]$name; Value = $val }
+ }
+
+ if ($raw.Count -eq 0) {
+ return @{ Ok = $false; Error = 'No usable targets. Each target needs subscriptionId/spoke plus percentage or allocatedShared.'; Values = @() }
+ }
+
+ $sum = ($raw | Measure-Object -Property Value -Sum).Sum
+ if ($sum -le 0) {
+ return @{ Ok = $false; Error = ('Target weights/percentages sum to {0}; cannot build a rule.' -f $sum); Values = @() }
+ }
+
+ $scaled = foreach ($r in $raw) {
+ [PSCustomObject]@{ Name = $r.Name; Percentage = [math]::Round(($r.Value / $sum) * 100, 2) }
+ }
+ $scaled = @($scaled)
+
+ # Fix rounding residual so the rule sums to exactly 100.00.
+ $pSum = ($scaled | Measure-Object -Property Percentage -Sum).Sum
+ $residual = [math]::Round(100 - $pSum, 2)
+ if ($residual -ne 0) {
+ $top = $scaled | Sort-Object -Property Percentage -Descending | Select-Object -First 1
+ $top.Percentage = [math]::Round($top.Percentage + $residual, 2)
+ }
+
+ return @{
+ Ok = $true
+ Error = $null
+ Values = @($scaled | ForEach-Object { @{ name = $_.Name; percentage = $_.Percentage } })
+ }
+}
+
+function Set-CostAllocationRule {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')]
+ param(
+ [Parameter(Mandatory)]
+ [string]$BillingAccountId,
+
+ [Parameter(Mandatory)]
+ [string]$RuleName,
+
+ [Parameter()]
+ [string[]]$SourceResourceGroup,
+
+ [Parameter()]
+ [string[]]$SourceSubscriptionId,
+
+ [Parameter(Mandatory)]
+ [object[]]$Targets,
+
+ [Parameter()]
+ [ValidateSet('SubscriptionId', 'ResourceGroupName')]
+ [string]$TargetDimension = 'SubscriptionId',
+
+ [Parameter()]
+ [ValidateSet('Active', 'NotActive')]
+ [string]$Status = 'Active',
+
+ [Parameter()]
+ [string]$Description,
+
+ [Parameter()]
+ [switch]$Apply,
+
+ [Parameter()]
+ [string]$ConfirmationToken
+ )
+
+ $apiVersion = '2025-03-01'
+
+ # ---- Validate inputs (fail before building anything) ----
+ if ($RuleName -notmatch '^[A-Za-z0-9_\-]+$' -or $RuleName.Length -gt 260) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = "RuleName '$RuleName' is invalid. Use only letters, digits, '_' and '-' (max 260 chars)."
+ }
+ }
+
+ $srcRg = @($SourceResourceGroup | Where-Object { $_ -and $_.Trim().Length -gt 0 })
+ $srcSub = @($SourceSubscriptionId | Where-Object { $_ -and $_.Trim().Length -gt 0 })
+ if ($srcRg.Count -gt 0 -and $srcSub.Count -gt 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = 'Provide a source as EITHER sourceResourceGroup OR sourceSubscriptionId, not both. A rule has one source resource type.'
+ }
+ }
+ if ($srcRg.Count -eq 0 -and $srcSub.Count -eq 0) {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = 'A source is required: pass sourceResourceGroup (hub RG) or sourceSubscriptionId (hub subscription).'
+ }
+ }
+
+ $source = if ($srcRg.Count -gt 0) {
+ @{ name = 'ResourceGroupName'; resourceType = 'Dimension'; values = @($srcRg) }
+ }
+ else {
+ @{ name = 'SubscriptionId'; resourceType = 'Dimension'; values = @($srcSub) }
+ }
+ if ($source.values.Count -gt 25) {
+ return [PSCustomObject]@{ HasData = $false; Error = 'A cost allocation rule source allows at most 25 values.' }
+ }
+
+ # ---- Normalize target percentages to sum exactly 100.00 ----
+ $norm = ConvertTo-AllocationPercentages -Targets $Targets -TargetDimension $TargetDimension
+ if (-not $norm.Ok) {
+ return [PSCustomObject]@{ HasData = $false; Error = $norm.Error }
+ }
+ $targetValues = @($norm.Values)
+ if ($targetValues.Count -gt 25) {
+ return [PSCustomObject]@{ HasData = $false; Error = 'A cost allocation rule allows at most 25 target values.' }
+ }
+ $pctTotal = [math]::Round((($targetValues | ForEach-Object { [double]$_.percentage } | Measure-Object -Sum).Sum), 2)
+
+ $target = @{
+ name = $TargetDimension
+ policyType = 'FixedProportion'
+ resourceType = 'Dimension'
+ values = $targetValues
+ }
+
+ $desc = if ($Description) { $Description } else { "FinOps Multitool shared-cost allocation ($RuleName)" }
+
+ $bodyObj = @{
+ properties = @{
+ description = $desc
+ status = $Status
+ details = @{
+ sourceResources = @($source)
+ targetResources = @($target)
+ }
+ }
+ }
+ $bodyJson = $bodyObj | ConvertTo-Json -Depth 12
+
+ $path = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountId/providers/Microsoft.CostManagement/costAllocationRules/$RuleName" + "?api-version=$apiVersion"
+
+ $previewTargets = @($targetValues | ForEach-Object {
+ [PSCustomObject]@{ Name = $_.name; Percentage = $_.percentage }
+ })
+
+ # ---- Route through the configurable write-safety gate ----
+ # A cost allocation rule lives at billing-account scope (no sub/RG). The
+ # fingerprint binds the EXACT rule body so Enforced mode cannot apply a
+ # different source/target split than was previewed. Reallocating cost is
+ # reversible (update/deactivate/delete the rule) so impact is left at 0.
+ $ruleResourceId = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountId/providers/Microsoft.CostManagement/costAllocationRules/$RuleName"
+ $fpExtra = @{
+ billingAccountId = $BillingAccountId
+ status = $Status
+ source = ($source.name + ':' + ((@($source.values) | Sort-Object) -join ','))
+ targetDimension = $TargetDimension
+ targets = ((@($targetValues | ForEach-Object { "$($_.name)=$($_.percentage)" }) | Sort-Object) -join ';')
+ }
+ $decision = Resolve-WriteDecision -ToolName 'set_cost_allocation_rule' -Operation 'CreateUpdateCostAllocationRule' `
+ -ResourceId $ruleResourceId -EstimatedMonthlyImpact 0 -Reversible $true `
+ -Apply:$Apply -ConfirmationToken $ConfirmationToken -FingerprintExtra $fpExtra
+
+ # ---- BLOCKED by mode/guardrails/enforcement ----
+ if ($decision.Decision -eq 'Blocked') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Mode = 'Blocked'
+ Applied = $false
+ Error = $decision.Reason
+ GuardrailViolations = @($decision.GuardrailViolations)
+ WriteMode = $decision.Mode
+ BillingAccountId = $BillingAccountId
+ RuleName = $RuleName
+ }
+ }
+
+ # ---- DRY RUN (default): show what WOULD be written, mutate nothing ----
+ if ($decision.Decision -eq 'Preview') {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'DryRun'
+ Applied = $false
+ WriteMode = $decision.Mode
+ Warning = "PREVIEW ONLY - nothing was written to Azure. This rule changes chargeback/cost allocation. Show this preview to the user and get explicit approval, then re-run with apply=true to write it. $($decision.Reason)"
+ Method = 'PUT'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ BillingAccountId = $BillingAccountId
+ RuleName = $RuleName
+ Status = $Status
+ Source = [PSCustomObject]@{ Dimension = $source.name; Values = @($source.values) }
+ Targets = $previewTargets
+ PercentageTotal = $pctTotal
+ RequestBody = $bodyObj
+ ConfirmationToken = $decision.ConfirmationToken
+ RequiresToken = $decision.RequiresToken
+ NextStep = if ($decision.RequiresToken) {
+ 'Enforced mode: re-run set_cost_allocation_rule with apply=true AND confirmationToken=, after user confirmation.'
+ }
+ else {
+ 'Re-run set_cost_allocation_rule with apply=true (after user confirmation) to create/update this rule.'
+ }
+ }
+ }
+
+ # ---- APPLY (decision = Proceed): actually create/update the rule ----
+ Write-Host " Writing cost allocation rule '$RuleName' on billing account '$BillingAccountId'..." -ForegroundColor Yellow
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PUT' -Payload $bodyJson
+
+ $status = if ($resp) { [int]$resp.StatusCode } else { 0 }
+ $ok = ($status -eq 200 -or $status -eq 201)
+ $respObj = $null
+ if ($resp -and $resp.Content) {
+ try { $respObj = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { $respObj = $resp.Content }
+ }
+
+ $errMsg = $null
+ if (-not $ok) {
+ $errMsg = "PUT returned $status."
+ if ($respObj -and $respObj.error -and $respObj.error.message) {
+ $errMsg += " $($respObj.error.message)"
+ }
+ }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'Apply'
+ Applied = $ok
+ WriteMode = $decision.Mode
+ StatusCode = $status
+ Warning = if ($ok) { 'Cost allocation rule written. It changes how shared cost is charged back; allow time for Cost Management to reprocess.' } else { $null }
+ Error = $errMsg
+ Method = 'PUT'
+ Uri = "$(Get-FinOpsArmEndpoint)$path"
+ BillingAccountId = $BillingAccountId
+ RuleName = $RuleName
+ Status = $Status
+ Source = [PSCustomObject]@{ Dimension = $source.name; Values = @($source.values) }
+ Targets = $previewTargets
+ PercentageTotal = $pctTotal
+ Response = $respObj
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1
new file mode 100644
index 000000000..b7b31e68e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1
@@ -0,0 +1,157 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# STOP-IDLEVM.PS1
+# AZURE FINOPS MULTITOOL - Deallocate an Idle VM
+###########################################################################
+# Purpose: Deallocate (stop) a single idle VM found by scan_idle_vms so
+# it stops billing for compute. Deallocate is REVERSIBLE - the
+# VM can be started again and keeps its disks and config.
+#
+# Description:
+# 1. Validates the resource id (Microsoft.Compute/virtualMachines only).
+# 2. Reads the VM power state; no-ops if it is already deallocated.
+# 3. Routes through the configurable write-safety gate (dry-run by
+# default; token required only in Enforced mode).
+# Note: deallocate releases the dynamic public IP and ephemeral state;
+# it does NOT delete the VM or its disks.
+#
+# ── Parameters ──────────────────────────────────────────────────────
+# ResourceId Full ARM resource ID of the VM
+# Apply Omitted = dry-run preview. Present = deallocate.
+# ConfirmationToken Required only in Enforced mode (from the preview)
+#
+# Usage: Stop-IdleVm -ResourceId [-Apply]
+###########################################################################
+
+function Stop-IdleVm {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')]
+ param(
+ [Parameter(Mandatory)]
+ [string]$ResourceId,
+
+ [Parameter()]
+ [switch]$Apply,
+
+ [Parameter()]
+ [string]$ConfirmationToken
+ )
+
+ $apiVersion = '2024-07-01'
+
+ if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') {
+ return [PSCustomObject]@{
+ HasData = $false
+ Error = 'This tool only deallocates Microsoft.Compute/virtualMachines. Pass a VM resource ID.'
+ }
+ }
+ $vmName = $Matches.name
+
+ # ---- Read power state (instanceView) ----
+ $ivPath = "$ResourceId/instanceView`?api-version=$apiVersion"
+ $ivResp = Invoke-AzRestMethodWithRetry -Path $ivPath -Method 'GET'
+ $ivStatus = if ($ivResp) { [int]$ivResp.StatusCode } else { 0 }
+ if ($ivStatus -eq 404) {
+ return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' }
+ }
+ if ($ivStatus -lt 200 -or $ivStatus -ge 300) {
+ $gErr = $null
+ if ($ivResp -and $ivResp.Content) { try { $gErr = ($ivResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $ivResp.Content } }
+ return [PSCustomObject]@{ HasData = $false; Error = "Could not read VM power state (HTTP $ivStatus). $gErr"; ResourceId = $ResourceId }
+ }
+
+ $iv = $null
+ try { $iv = $ivResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {}
+ $powerState = 'unknown'
+ if ($iv -and $iv.statuses) {
+ $ps = $iv.statuses | Where-Object { $_.code -like 'PowerState/*' } | Select-Object -First 1
+ if ($ps) { $powerState = ($ps.code -replace '^PowerState/', '') }
+ }
+
+ # ---- Already stopped? No-op. ----
+ if ($powerState -in @('deallocated', 'deallocating', 'stopped')) {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'NoOp'
+ Applied = $false
+ Note = "VM '$vmName' is already '$powerState'. Nothing to do."
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ PowerState = $powerState
+ }
+ }
+
+ $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null }
+ $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null }
+ # Tags require a separate GET; deallocate is reversible so a light touch
+ # is fine - read tags from the VM resource for guardrail checks.
+ $tagHash = @{}
+ $vmResp = Invoke-AzRestMethodWithRetry -Path "$ResourceId`?api-version=$apiVersion" -Method 'GET'
+ if ($vmResp -and [int]$vmResp.StatusCode -ge 200 -and [int]$vmResp.StatusCode -lt 300) {
+ try { $vmObj = $vmResp.Content | ConvertFrom-Json; if ($vmObj.tags) { foreach ($t in $vmObj.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } } } catch {}
+ }
+
+ # ---- Write-safety gate (reversible -> impact 0) ----
+ $decision = Resolve-WriteDecision -ToolName 'remediate_deallocate_vm' -Operation 'Deallocate' `
+ -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash `
+ -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken
+
+ if ($decision.Decision -eq 'Blocked') {
+ return [PSCustomObject]@{
+ HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason
+ GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode
+ ResourceId = $ResourceId; ResourceName = $vmName
+ }
+ }
+
+ $deallocPath = "$ResourceId/deallocate`?api-version=$apiVersion"
+
+ if ($decision.Decision -eq 'Preview') {
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'DryRun'
+ Applied = $false
+ WriteMode = $decision.Mode
+ Warning = "PREVIEW ONLY - the VM was not stopped. Deallocate is REVERSIBLE (you can start the VM again; disks are kept). $($decision.Reason)"
+ Method = 'POST'
+ Uri = "$(Get-FinOpsArmEndpoint)$deallocPath"
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ CurrentPowerState = $powerState
+ ConfirmationToken = $decision.ConfirmationToken
+ RequiresToken = $decision.RequiresToken
+ NextStep = if ($decision.RequiresToken) {
+ 'Enforced mode: re-run with apply=true AND confirmationToken=, after user confirmation.'
+ }
+ else { 'Re-run remediate_deallocate_vm with apply=true (after user confirmation) to deallocate this VM.' }
+ }
+ }
+
+ # ---- Proceed: POST deallocate ----
+ Write-Host " Deallocating idle VM '$vmName'..." -ForegroundColor Yellow
+ $resp = Invoke-AzRestMethodWithRetry -Path $deallocPath -Method 'POST'
+ $status = if ($resp) { [int]$resp.StatusCode } else { 0 }
+ $ok = $status -in @(200, 202)
+ $errMsg = $null
+ if (-not $ok) {
+ $errMsg = "POST deallocate returned $status."
+ if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} }
+ }
+
+ return [PSCustomObject]@{
+ HasData = $true
+ Mode = 'Apply'
+ Applied = $ok
+ WriteMode = $decision.Mode
+ StatusCode = $status
+ Warning = if ($ok) { "Deallocate started (async). Reversible - start the VM to bring it back." } else { $null }
+ Error = $errMsg
+ Method = 'POST'
+ Uri = "$(Get-FinOpsArmEndpoint)$deallocPath"
+ ResourceId = $ResourceId
+ ResourceName = $vmName
+ Async = ($status -eq 202)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1
new file mode 100644
index 000000000..2a6d0c511
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1
@@ -0,0 +1,300 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# CONFIRM-WRITEACTION.PS1
+# AZURE FINOPS MULTITOOL - Configurable Write-Safety Core
+###########################################################################
+# Purpose: One choke point that every MUTATING tool routes through, so
+# writeback is safe whether a human is driving an AI chat or an
+# autonomous agent is acting unattended.
+#
+# Description:
+# Safety is OPTIONAL and configurable via FINOPS_WRITE_MODE. Writes are
+# OPT-IN: the server is read-only out of the box and a mutating tool only
+# acts after an operator deliberately enables a write mode.
+# ReadOnly (default) - all writes blocked. Set FINOPS_WRITE_MODE to
+# Interactive or Enforced to enable remediation.
+# Interactive - platform-agnostic AI chat. apply=true is
+# honored directly (the human/AI client showed the preview). A
+# confirmation token is still issued, but NOT required. Low friction.
+# Enforced - autonomous-safe. apply=true is REJECTED unless
+# it carries the exact confirmation token returned by the matching
+# dry-run. An agent cannot skip the preview or apply a different /
+# larger change than was previewed.
+#
+# Guardrails (apply in BOTH Interactive and Enforced, all configurable):
+# - Protected tag keys/values (e.g. do-not-delete) - never mutate
+# - Protected resource groups / subscriptions (deny-list patterns)
+# - Estimated monthly $ impact cap
+# - Blast-radius cap (max writes per rolling window)
+# Every preview and apply is written to an append-only audit log.
+#
+# ── Configuration (env) ─────────────────────────────────────────────
+# FINOPS_WRITE_MODE Interactive | Enforced | ReadOnly
+# FINOPS_WRITE_MAX_IMPACT Max estimated monthly USD impact (0 = no cap)
+# FINOPS_WRITE_MAX_PER_WINDOW Max writes per window (0 = no cap)
+# FINOPS_WRITE_WINDOW_MIN Rolling window minutes for blast radius
+# FINOPS_PROTECTED_TAGS Comma list of tag keys that block a write
+# FINOPS_PROTECTED_RGS Comma list of resource-group name patterns
+# FINOPS_PROTECTED_SUBS Comma list of subscription IDs to deny
+# FINOPS_AUDIT_LOG Audit log path
+#
+# Usage: $d = Resolve-WriteDecision -ToolName x -Operation Delete ...
+###########################################################################
+
+# -- Per-process state -----------------------------------------------------
+$script:FinOpsPendingConfirmations = @{}
+$script:FinOpsWriteHistory = New-Object System.Collections.ArrayList
+
+function Get-FinOpsDefaultAuditLogPath {
+ # Durable, per-user audit location that survives reboots - unlike %TEMP%,
+ # which the user (or OS cleanup) can clear, weakening an append-only trail.
+ # Falls back to the temp dir only if the profile path cannot be resolved or
+ # created. Override entirely with FINOPS_AUDIT_LOG.
+ $base = [Environment]::GetFolderPath('LocalApplicationData')
+ if ([string]::IsNullOrWhiteSpace($base)) { $base = [System.IO.Path]::GetTempPath() }
+ $dir = Join-Path $base 'FinOpsMultitool'
+ try {
+ if (-not (Test-Path -LiteralPath $dir)) {
+ New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null
+ }
+ }
+ catch {
+ $dir = [System.IO.Path]::GetTempPath()
+ }
+ return (Join-Path $dir 'finops-multitool-audit.log')
+}
+
+function Initialize-FinOpsWritePolicy {
+ $envList = {
+ param($v)
+ if ([string]::IsNullOrWhiteSpace($v)) { @() }
+ else { @($v -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
+ }
+
+ $mode = $env:FINOPS_WRITE_MODE
+ if ($mode -notin @('Interactive', 'Enforced', 'ReadOnly')) { $mode = 'ReadOnly' }
+
+ $script:FinOpsWritePolicy = [ordered]@{
+ Mode = $mode
+ MaxEstimatedImpact = [double]($env:FINOPS_WRITE_MAX_IMPACT | ForEach-Object { if ($_) { $_ } else { 0 } })
+ MaxWritesPerWindow = [int]( $env:FINOPS_WRITE_MAX_PER_WINDOW | ForEach-Object { if ($_) { $_ } else { 0 } })
+ WindowMinutes = [int]( $env:FINOPS_WRITE_WINDOW_MIN | ForEach-Object { if ($_) { $_ } else { 60 } })
+ ProtectedTagKeys = & $envList ($env:FINOPS_PROTECTED_TAGS)
+ ProtectedResourceGroups = & $envList ($env:FINOPS_PROTECTED_RGS)
+ ProtectedSubscriptions = & $envList ($env:FINOPS_PROTECTED_SUBS)
+ TokenTtlSeconds = 300
+ AuditLogPath = if ($env:FINOPS_AUDIT_LOG) { $env:FINOPS_AUDIT_LOG } else { Get-FinOpsDefaultAuditLogPath }
+ }
+ # Built-in safety defaults so a fresh deploy is never wide open on the
+ # most dangerous classes even before an operator tunes the env vars.
+ if ($script:FinOpsWritePolicy.ProtectedTagKeys.Count -eq 0) {
+ $script:FinOpsWritePolicy.ProtectedTagKeys = @('do-not-delete', 'DoNotDelete', 'lock', 'protected')
+ }
+ return $script:FinOpsWritePolicy
+}
+
+function Get-FinOpsWritePolicy {
+ if (-not $script:FinOpsWritePolicy) { Initialize-FinOpsWritePolicy | Out-Null }
+ return $script:FinOpsWritePolicy
+}
+
+function Set-FinOpsWritePolicy {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Updates only the in-process write-policy hashtable; it does not change system or Azure state.')]
+ param([hashtable]$Settings)
+ if (-not $script:FinOpsWritePolicy) { Initialize-FinOpsWritePolicy | Out-Null }
+ foreach ($k in $Settings.Keys) {
+ if ($script:FinOpsWritePolicy.Contains($k)) { $script:FinOpsWritePolicy[$k] = $Settings[$k] }
+ }
+ return $script:FinOpsWritePolicy
+}
+
+# -- Stable fingerprint of the exact intended change ----------------------
+function Get-WriteFingerprint {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$ToolName,
+ [Parameter(Mandatory)][string]$Operation,
+ [Parameter(Mandatory)][string]$ResourceId,
+ [hashtable]$Extra
+ )
+ $parts = @($ToolName.ToLower(), $Operation.ToLower(), $ResourceId.ToLower())
+ if ($Extra) {
+ foreach ($k in ($Extra.Keys | Sort-Object)) { $parts += "$k=$($Extra[$k])".ToLower() }
+ }
+ $canonical = ($parts -join '|')
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($canonical))
+ $sha.Dispose()
+ return -join ($bytes | ForEach-Object { $_.ToString('x2') })
+}
+
+function New-WriteConfirmation {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Issues an in-process, short-lived confirmation token; it does not change system or Azure state.')]
+ param([Parameter(Mandatory)][string]$Fingerprint)
+ $policy = Get-FinOpsWritePolicy
+ $token = [guid]::NewGuid().ToString('N')
+ $script:FinOpsPendingConfirmations[$token] = @{
+ Fingerprint = $Fingerprint
+ Expires = (Get-Date).AddSeconds($policy.TokenTtlSeconds)
+ }
+ # Opportunistic cleanup of expired tokens.
+ $now = Get-Date
+ @($script:FinOpsPendingConfirmations.Keys) | ForEach-Object {
+ if ($script:FinOpsPendingConfirmations[$_].Expires -lt $now) { $script:FinOpsPendingConfirmations.Remove($_) }
+ }
+ return $token
+}
+
+function Test-WriteConfirmation {
+ [CmdletBinding()]
+ param(
+ [string]$Token,
+ [Parameter(Mandatory)][string]$Fingerprint
+ )
+ if ([string]::IsNullOrWhiteSpace($Token)) { return @{ Valid = $false; Reason = 'No confirmationToken supplied.' } }
+ $entry = $script:FinOpsPendingConfirmations[$Token]
+ if (-not $entry) { return @{ Valid = $false; Reason = 'confirmationToken is unknown, already used, or expired. Re-run the dry-run preview to get a fresh token.' } }
+ # Single use, regardless of outcome.
+ $script:FinOpsPendingConfirmations.Remove($Token)
+ if ($entry.Expires -lt (Get-Date)) { return @{ Valid = $false; Reason = 'confirmationToken has expired. Re-run the dry-run preview.' } }
+ if ($entry.Fingerprint -ne $Fingerprint) { return @{ Valid = $false; Reason = 'confirmationToken does not match this exact change. The intended action differs from what was previewed; re-run the dry-run preview.' } }
+ return @{ Valid = $true; Reason = $null }
+}
+
+# -- Guardrails (protected resources, caps) -------------------------------
+function Test-WriteGuardrails {
+ [CmdletBinding()]
+ param(
+ [string]$ResourceId,
+ [string]$SubscriptionId,
+ [string]$ResourceGroup,
+ [hashtable]$Tags,
+ [double]$EstimatedMonthlyImpact = 0
+ )
+ $policy = Get-FinOpsWritePolicy
+ $violations = @()
+
+ if ($SubscriptionId -and ($policy.ProtectedSubscriptions -contains $SubscriptionId)) {
+ $violations += "Subscription '$SubscriptionId' is on the protected (deny) list."
+ }
+ if ($ResourceGroup) {
+ foreach ($pat in $policy.ProtectedResourceGroups) {
+ if ($ResourceGroup -like $pat -or $ResourceGroup -eq $pat) {
+ $violations += "Resource group '$ResourceGroup' matches a protected pattern ('$pat')."
+ break
+ }
+ }
+ }
+ if ($Tags) {
+ foreach ($pk in $policy.ProtectedTagKeys) {
+ $hit = $Tags.Keys | Where-Object { $_ -ieq $pk }
+ if ($hit) { $violations += "Resource carries a protected tag ('$hit') - refusing to mutate it." }
+ }
+ }
+ if ($policy.MaxEstimatedImpact -gt 0 -and $EstimatedMonthlyImpact -gt $policy.MaxEstimatedImpact) {
+ $violations += ("Estimated monthly impact `$$([math]::Round($EstimatedMonthlyImpact,2)) exceeds the configured cap `$$($policy.MaxEstimatedImpact).")
+ }
+ if ($policy.MaxWritesPerWindow -gt 0) {
+ $cutoff = (Get-Date).AddMinutes(-1 * $policy.WindowMinutes)
+ $recent = @($script:FinOpsWriteHistory | Where-Object { $_ -gt $cutoff })
+ if ($recent.Count -ge $policy.MaxWritesPerWindow) {
+ $violations += "Blast-radius cap reached: $($recent.Count) writes in the last $($policy.WindowMinutes) min (max $($policy.MaxWritesPerWindow)). Wait or raise FINOPS_WRITE_MAX_PER_WINDOW."
+ }
+ }
+ return @{ Allowed = ($violations.Count -eq 0); Violations = $violations }
+}
+
+function Write-FinOpsAudit {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][hashtable]$Entry)
+ try {
+ $policy = Get-FinOpsWritePolicy
+ $Entry['timestamp'] = (Get-Date -Format 'o')
+ $line = ($Entry | ConvertTo-Json -Depth 6 -Compress)
+ Add-Content -Path $policy.AuditLogPath -Value $line -Encoding utf8 -ErrorAction SilentlyContinue
+ }
+ catch { Write-Warning "Audit log write failed: $($_.Exception.Message)" }
+}
+
+# -- The single decision the mutating tools call --------------------------
+function Resolve-WriteDecision {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$ToolName,
+ [Parameter(Mandatory)][string]$Operation,
+ [Parameter(Mandatory)][string]$ResourceId,
+ [string]$SubscriptionId,
+ [string]$ResourceGroup,
+ [hashtable]$Tags,
+ [double]$EstimatedMonthlyImpact = 0,
+ [bool]$Reversible = $true,
+ [switch]$Apply,
+ [string]$ConfirmationToken,
+ [hashtable]$FingerprintExtra
+ )
+ $policy = Get-FinOpsWritePolicy
+ $fp = Get-WriteFingerprint -ToolName $ToolName -Operation $Operation -ResourceId $ResourceId -Extra $FingerprintExtra
+
+ $result = [ordered]@{
+ Mode = $policy.Mode
+ Decision = $null # Preview | Proceed | Blocked
+ Reason = $null
+ ConfirmationToken = $null
+ RequiresToken = ($policy.Mode -eq 'Enforced')
+ GuardrailViolations = @()
+ Reversible = $Reversible
+ Fingerprint = $fp
+ }
+
+ # Read-only server: nothing writes, ever.
+ if ($policy.Mode -eq 'ReadOnly') {
+ $result.Decision = 'Blocked'
+ $result.Reason = 'Server is in ReadOnly write mode (FINOPS_WRITE_MODE=ReadOnly, the default). No mutations are permitted. To enable remediation, set FINOPS_WRITE_MODE=Interactive (human-in-the-loop) or =Enforced (autonomous-safe; apply=true also requires the confirmation token from a dry-run).'
+ Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason }
+ return [PSCustomObject]$result
+ }
+
+ # Guardrails apply in every mode (these are the "costly mistake" guard).
+ $g = Test-WriteGuardrails -ResourceId $ResourceId -SubscriptionId $SubscriptionId -ResourceGroup $ResourceGroup -Tags $Tags -EstimatedMonthlyImpact $EstimatedMonthlyImpact
+ if (-not $g.Allowed) {
+ $result.Decision = 'Blocked'
+ $result.GuardrailViolations = @($g.Violations)
+ $result.Reason = 'Blocked by write guardrails: ' + ($g.Violations -join ' ')
+ Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason; violations = @($g.Violations) }
+ return [PSCustomObject]$result
+ }
+
+ # Dry-run preview: issue a token (usable later in any mode).
+ if (-not $Apply) {
+ $result.Decision = 'Preview'
+ $result.ConfirmationToken = New-WriteConfirmation -Fingerprint $fp
+ $result.Reason = if ($policy.Mode -eq 'Enforced') {
+ 'Preview only. Server is in Enforced mode: re-run with apply=true AND confirmationToken= to execute.'
+ }
+ else {
+ 'Preview only. Show this to the user; re-run with apply=true to execute (Interactive mode does not require the token).'
+ }
+ Write-FinOpsAudit -Entry @{ event = 'preview'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; mode = $policy.Mode; reversible = $Reversible }
+ return [PSCustomObject]$result
+ }
+
+ # Apply path.
+ if ($policy.Mode -eq 'Enforced') {
+ $chk = Test-WriteConfirmation -Token $ConfirmationToken -Fingerprint $fp
+ if (-not $chk.Valid) {
+ $result.Decision = 'Blocked'
+ $result.Reason = "Enforced mode: $($chk.Reason)"
+ Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason }
+ return [PSCustomObject]$result
+ }
+ }
+
+ $result.Decision = 'Proceed'
+ [void]$script:FinOpsWriteHistory.Add((Get-Date))
+ Write-FinOpsAudit -Entry @{ event = 'apply'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; mode = $policy.Mode; reversible = $Reversible; estimatedMonthlyImpact = $EstimatedMonthlyImpact }
+ return [PSCustomObject]$result
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1
new file mode 100644
index 000000000..b4b29b22b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1
@@ -0,0 +1,909 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COSTEXPORT.PS1
+# COST MANAGEMENT EXPORT DETECTION & FAST READ
+###########################################################################
+# Purpose: Detect existing Cost Management exports (any export, not just a
+# FinOps Hub) and read their CSV data from blob storage so the
+# server can serve cost tools from a pre-materialized export
+# instead of the throttle-bound live Cost Management query API.
+# Date: Created for FinOps Multitool generic export detection
+#
+# Description:
+# Read-only port of the GUI scanner's export module. Supplies the same
+# cost data contracts the FinOps Hub fast path provides, but sourced from
+# any Cost Management export the caller has read access to:
+# 1. Find-CostExport - enumerate exports at each subscription scope
+# 2. Get-CostExportData - read the newest run's CSV into normalized rows
+# 3. ConvertTo-*FromExport - shape rows into the cost-tool data contracts
+#
+# Format: CSV only. Parquet exports are detected and reported but not parsed
+# natively in PowerShell.
+#
+# ── Parameters ──────────────────────────────────────────────
+# (per-function; see each function below)
+#
+# Prerequisites:
+# - Get-PlainAccessToken + Invoke-AzRestMethodWithRetry helpers loaded
+# - Storage Blob Data Reader on the export's storage account for the read
+#
+# Reference: https://learn.microsoft.com/rest/api/cost-management/exports
+###########################################################################
+
+# -- Blob endpoint suffix for the active cloud ----------------------------
+function Get-ExportBlobSuffix {
+ param([string]$Environment = 'AzureCloud')
+ switch ($Environment) {
+ 'AzureUSGovernment' { 'blob.core.usgovcloudapi.net' }
+ 'AzureChinaCloud' { 'blob.core.chinacloudapi.cn' }
+ default { 'blob.core.windows.net' }
+ }
+}
+
+# -- Storage blob data-plane REST call (list / get) -----------------------
+# Uses an AAD bearer token scoped to storage.azure.com. Returns the raw
+# response content (XML for list, CSV text for get) or $null on failure.
+function Invoke-StorageBlobRest {
+ param(
+ [Parameter(Mandatory)][string]$Uri,
+ [string]$StorageToken,
+ [int]$TimeoutSeconds = 60
+ )
+ if (-not $StorageToken) {
+ try { $StorageToken = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' }
+ catch { Write-Warning " Could not acquire storage token: $($_.Exception.Message)"; return $null }
+ }
+ $headers = @{
+ Authorization = "Bearer $StorageToken"
+ 'x-ms-version' = '2021-08-06'
+ }
+ try {
+ return Invoke-RestMethod -Uri $Uri -Headers $headers -Method GET -TimeoutSec $TimeoutSeconds -ErrorAction Stop
+ }
+ catch {
+ $code = $null
+ if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode }
+ Write-Warning " Storage request failed (HTTP $code): $Uri"
+ return $null
+ }
+}
+
+# -- Download a blob's raw bytes (binary-safe) ----------------------------
+# Invoke-RestMethod decodes a response body as text using the content-type
+# charset, which corrupts binary payloads - notably '.csv.gz' parts, where the
+# mangled bytes can no longer be gunzipped ("unsupported compression method").
+# Use HttpClient to read the exact bytes so gzip and plain CSV both decode.
+function Get-StorageBlobBytes {
+ param(
+ [Parameter(Mandatory)][string]$Uri,
+ [string]$StorageToken,
+ [int]$TimeoutSeconds = 120
+ )
+ if (-not $StorageToken) {
+ try { $StorageToken = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' }
+ catch { Write-Warning " Could not acquire storage token: $($_.Exception.Message)"; return $null }
+ }
+ $client = $null; $req = $null; $resp = $null
+ try {
+ $client = [System.Net.Http.HttpClient]::new()
+ $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
+ $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Uri)
+ [void]$req.Headers.TryAddWithoutValidation('Authorization', "Bearer $StorageToken")
+ [void]$req.Headers.TryAddWithoutValidation('x-ms-version', '2021-08-06')
+ $resp = $client.SendAsync($req).GetAwaiter().GetResult()
+ if (-not $resp.IsSuccessStatusCode) {
+ Write-Warning " Storage blob GET failed (HTTP $([int]$resp.StatusCode)): $Uri"
+ return $null
+ }
+ $data = $resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
+ # Unary comma stops PowerShell from unrolling the byte[] into a stream
+ # of individual bytes (which the caller would receive as an Object[]).
+ return , $data
+ }
+ catch { Write-Warning " Storage blob GET error: $($_.Exception.Message)"; return $null }
+ finally {
+ if ($resp) { $resp.Dispose() }
+ if ($req) { $req.Dispose() }
+ if ($client) { $client.Dispose() }
+ }
+}
+
+# -- Flat blob listing under a prefix -------------------------------------
+# Lists every blob under $Prefix (no delimiter = recursive). Robust to two
+# quirks: (1) Invoke-RestMethod often returns the list XML as a raw string
+# (with a UTF-8 BOM) instead of an XmlDocument, so parse defensively; and
+# (2) follows NextMarker so large accounts are not silently truncated.
+function Get-StorageBlobList {
+ param(
+ [Parameter(Mandatory)][string]$BlobBase,
+ [Parameter(Mandatory)][string]$Container,
+ [string]$Prefix = '',
+ [string]$StorageToken
+ )
+ $out = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $marker = $null
+ $listed = $false
+ do {
+ $listUri = "$BlobBase/$Container`?restype=container&comp=list"
+ if ($Prefix) { $listUri += "&prefix=$([uri]::EscapeDataString($Prefix))" }
+ if ($marker) { $listUri += "&marker=$([uri]::EscapeDataString($marker))" }
+ $resp = Invoke-StorageBlobRest -Uri $listUri -StorageToken $StorageToken
+ if (-not $resp) { break }
+ $listed = $true
+
+ # Normalize the response into an XmlDocument.
+ $doc = $null
+ if ($resp -is [System.Xml.XmlDocument]) {
+ $doc = $resp
+ }
+ elseif ($resp -is [string]) {
+ $txt = $resp
+ $i = $txt.IndexOf('.*?)/(?\d{8}-\d{8})/')
+ if (-not $m.Success) {
+ # No date-range folder: treat the directory holding the CSV
+ # as the export folder so flat layouts still surface.
+ $dir = [System.IO.Path]::GetDirectoryName($b.Name) -replace '\\', '/'
+ if (-not $dir) { continue }
+ $folder = $dir
+ }
+ else { $folder = $m.Groups['folder'].Value }
+ if ([string]::IsNullOrWhiteSpace($folder)) { continue }
+ if (-not $groups.ContainsKey($folder)) { $groups[$folder] = [System.Collections.Generic.List[PSCustomObject]]::new() }
+ [void]$groups[$folder].Add($b)
+ }
+
+ foreach ($folder in $groups.Keys) {
+ $segs = @($folder.Trim('/') -split '/')
+ $name = $segs[-1]
+ $root = if ($segs.Count -gt 1) { ($segs[0..($segs.Count - 2)] -join '/') } else { '' }
+
+ $key = ("$($sa.ResourceId)|$container|$name").ToLowerInvariant()
+ if ($KnownKeys.ContainsKey($key) -or $seen.ContainsKey($key)) { continue }
+ $seen[$key] = $true
+
+ $parts = $groups[$folder]
+ $lastRun = ($parts | ForEach-Object { $_.LastModified } | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1)
+ $partitioned = (@($parts | Where-Object { $_.Name -match 'part_' }).Count -gt 1)
+
+ # Infer the cost type from the folder name (best-effort, display only)
+ $type = if ($name -match 'amortiz') { 'AmortizedCost' }
+ elseif ($name -match 'actual') { 'ActualCost' }
+ elseif ($name -match 'focus') { 'FocusCost' }
+ else { 'Usage' }
+
+ [void]$found.Add([PSCustomObject]@{
+ Name = $name
+ SubId = $sa.SubId
+ SubName = $sa.SubName
+ Scope = $sa.ResourceId
+ ScopeKind = 'Storage'
+ ScopeLabel = "Storage: $($sa.Name)/$container"
+ Type = $type
+ Granularity = 'Daily'
+ Format = 'Csv'
+ Partitioned = $partitioned
+ StorageResourceId = $sa.ResourceId
+ Container = $container
+ RootFolder = $root
+ LastRunDate = $lastRun
+ })
+ }
+ }
+ }
+
+ return $found
+}
+
+# -- Read an export's newest CSV data into normalized rows ----------------
+# Lists blobs under the export's folder, locates the newest run, downloads
+# the CSV (or manifest-referenced CSV parts), and returns normalized rows.
+function Get-CostExportData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][object]$Export,
+ [string]$Environment = 'AzureCloud'
+ )
+
+ if ($Export.Format -and $Export.Format -notmatch 'csv') {
+ Write-Warning " Export '$($Export.Name)' is $($Export.Format) format - CSV required."
+ return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD'; Unsupported = $true }
+ }
+
+ # Parse the storage account name from its ARM resource id
+ if ($Export.StorageResourceId -notmatch '/storageAccounts/([^/]+)') {
+ Write-Warning " Could not parse storage account from export destination."
+ return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD' }
+ }
+ $account = $Matches[1]
+ $suffix = Get-ExportBlobSuffix -Environment $Environment
+ $blobBase = "https://$account.$suffix"
+ $container = $Export.Container
+ $root = ($Export.RootFolder).Trim('/')
+
+ $token = $null
+ try { $token = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' }
+ catch { Write-Warning " Storage token error: $($_.Exception.Message)"; return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD' } }
+
+ # Export layouts vary:
+ # Standard Cost Management export : {root}/{name}/{dateRange}/{runId}/*.csv
+ # FinOps Hub (manifest) export : subscriptions/{subId}/{name}/{dateRange}/{runTs}/{runId}/*.csv
+ # Try progressively looser prefixes until CSV data blobs are found. The
+ # subscription-scoped path is tried first so we don't accidentally pick up
+ # a different export's data from a whole-container scan.
+ $candidates = [System.Collections.Generic.List[string]]::new()
+ if ($Export.SubId) { [void]$candidates.Add("subscriptions/$($Export.SubId)/$($Export.Name)/") }
+ if ($root) {
+ [void]$candidates.Add("$root/$($Export.Name)/")
+ [void]$candidates.Add("$root/")
+ }
+ [void]$candidates.Add("$($Export.Name)/")
+ [void]$candidates.Add('')
+
+ $blobs = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $csvBlobs = @()
+ $usedPrefix = $null
+ $anyListed = $false
+ $seen = @{}
+ foreach ($prefix in $candidates) {
+ if ($seen.ContainsKey($prefix)) { continue }
+ $seen[$prefix] = $true
+
+ $listed = Get-StorageBlobList -BlobBase $blobBase -Container $container -Prefix $prefix -StorageToken $token
+ if ($listed.Listed) { $anyListed = $true }
+ $blobs = $listed.Blobs
+
+ $csvBlobs = @($blobs | Where-Object { $_.Name -match '\.csv(\.gz)?$' })
+ if ($csvBlobs.Count -gt 0) { $usedPrefix = $prefix; break }
+ }
+
+ if (-not $anyListed) {
+ Write-Warning " Could not list export blobs (storage access denied? needs Storage Blob Data Reader)."
+ return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD'; AccessDenied = $true }
+ }
+
+ if ($csvBlobs.Count -eq 0) {
+ $hasParquet = @($blobs | Where-Object { $_.Name -match '\.parquet$' }).Count -gt 0
+ $reason = if ($hasParquet) { 'Export writes Parquet, not CSV. Recreate the export with CSV format.' }
+ else { "No CSV data blobs found for export '$($Export.Name)' in container '$container'." }
+ Write-Warning " $reason"
+ return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD'; Unsupported = $hasParquet; Reason = $reason; NoData = $true }
+ }
+
+ $newest = ($csvBlobs | Sort-Object LastModified -Descending | Select-Object -First 1)
+ # A partitioned export writes multiple CSV parts in the same run folder.
+ # Group by the run folder (everything up to the last '/') of the newest blob.
+ $runFolder = ($newest.Name -replace '/[^/]+$', '/')
+ $runParts = @($csvBlobs | Where-Object { $_.Name -like "$runFolder*" })
+ if ($runParts.Count -eq 0) { $runParts = @($newest) }
+
+ $dataDate = ($runParts | Sort-Object LastModified -Descending | Select-Object -First 1).LastModified
+
+ # Download + parse each CSV part
+ $rows = [System.Collections.Generic.List[object]]::new()
+ $colMap = $null
+ $firstHeader = @()
+ foreach ($part in $runParts) {
+ $blobUri = "$blobBase/$container/$([uri]::EscapeUriString($part.Name))"
+ $bytes = Get-StorageBlobBytes -Uri $blobUri -StorageToken $token
+ if (-not $bytes) { continue }
+ $csvText = $null
+ if ($part.Name -match '\.gz$') {
+ $csvText = Expand-GzipText -Content $bytes
+ }
+ else { $csvText = [System.Text.Encoding]::UTF8.GetString($bytes) }
+ if (-not $csvText) { continue }
+
+ $parsed = @($csvText | ConvertFrom-Csv)
+ if ($parsed.Count -eq 0) { continue }
+ if (-not $colMap) {
+ $firstHeader = @($parsed[0].PSObject.Properties.Name)
+ $colMap = Resolve-ExportColumns -Header $firstHeader
+ }
+ foreach ($r in $parsed) { [void]$rows.Add($r) }
+ }
+
+ # Determine currency from the first row that has one
+ $currency = 'USD'
+ if ($colMap -and $colMap.Currency) {
+ $c = ($rows | Where-Object { $_.$($colMap.Currency) } | Select-Object -First 1)
+ if ($c) { $currency = $c.$($colMap.Currency) }
+ }
+
+ return [PSCustomObject]@{
+ Rows = $rows
+ ColMap = $colMap
+ DataDate = $dataDate
+ Currency = $currency
+ RowCount = $rows.Count
+ Headers = $firstHeader
+ NoCostColumn = ($colMap -and -not $colMap.Cost)
+ NoData = ($rows.Count -eq 0)
+ }
+}
+
+# -- Internal: friendly resource type from an ARM resource id -------------
+function Get-ExportResourceType {
+ param([string]$ResourceId)
+ if ($ResourceId -match '/providers/([^/]+/[^/]+)/[^/]+$') {
+ return ($Matches[1] -replace '(?i)microsoft\.', '')
+ }
+ return 'Unknown'
+}
+
+# -- Converter: export rows -> Get-CostData costMap -----------------------
+function ConvertTo-CostDataFromExport {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][object]$ExportData,
+ [Parameter(Mandatory)][object[]]$Subscriptions
+ )
+ $costMap = @{}
+ $cm = $ExportData.ColMap
+ if (-not $cm -or -not $cm.Cost) { return $costMap }
+
+ # Map selected-sub GUIDs (lowercased) back to their canonical sub.Id key
+ $guidToKey = @{}
+ foreach ($s in $Subscriptions) {
+ $g = Get-GuidFromString -Value "$($s.Id)"
+ if ($g) { $guidToKey[$g.ToLower()] = $s.Id }
+ }
+
+ # Seed every selected sub so the UI shows them even at $0
+ foreach ($s in $Subscriptions) { $costMap[$s.Id] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } }
+
+ foreach ($r in $ExportData.Rows) {
+ # SubscriptionId may be a bare GUID (classic) or a /subscriptions/
+ # path (FOCUS SubAccountId). Fall back to ResourceId when absent.
+ $rawSub = if ($cm.SubscriptionId) { "$($r.$($cm.SubscriptionId))" } else { '' }
+ if ([string]::IsNullOrWhiteSpace($rawSub) -and $cm.ResourceId) { $rawSub = "$($r.$($cm.ResourceId))" }
+ $g = Get-GuidFromString -Value $rawSub
+ if (-not $g) { continue }
+ $key = if ($guidToKey.ContainsKey($g.ToLower())) { $guidToKey[$g.ToLower()] } else { $g }
+ $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null
+ if (-not $costMap.ContainsKey($key)) {
+ $costMap[$key] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency }
+ }
+ $costMap[$key].Actual += $cost
+ }
+
+ # Linear month-to-date projection for a sensible forecast
+ $now = Get-Date
+ $daysInMo = [DateTime]::DaysInMonth($now.Year, $now.Month)
+ $dayOfMo = [math]::Max(1, $now.Day)
+ foreach ($k in @($costMap.Keys)) {
+ $costMap[$k].Actual = [math]::Round($costMap[$k].Actual, 2)
+ $costMap[$k].Forecast = [math]::Round($costMap[$k].Actual / $dayOfMo * $daysInMo, 2)
+ }
+ return $costMap
+}
+
+# -- Converter: export rows -> Get-ResourceCosts rows ---------------------
+function ConvertTo-ResourceCostsFromExport {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][object]$ExportData,
+ [Parameter(Mandatory)][object[]]$Subscriptions
+ )
+ $out = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $cm = $ExportData.ColMap
+ if (-not $cm -or -not $cm.Cost -or -not $cm.ResourceId) { return $out }
+
+ $subNameMap = @{}
+ foreach ($s in $Subscriptions) { $subNameMap[$s.Id.ToLower()] = $s.Name }
+
+ $agg = @{}
+ foreach ($r in $ExportData.Rows) {
+ $rid = if ($cm.ResourceId) { "$($r.$($cm.ResourceId))".Trim() } else { '' }
+ if (-not $rid) { continue }
+ $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null
+ $key = $rid.ToLower()
+ if (-not $agg.ContainsKey($key)) {
+ $subId = ''
+ if ($rid -match '/subscriptions/([^/]+)/') { $subId = $Matches[1].ToLower() }
+ $rg = if ($cm.ResourceGroup) { "$($r.$($cm.ResourceGroup))" } else { '' }
+ if (-not $rg -and $rid -match '/resourcegroups/([^/]+)/') { $rg = $Matches[1] }
+ $agg[$key] = @{
+ ResourcePath = $rid
+ ResourceGroup = $rg
+ ResourceType = Get-ExportResourceType -ResourceId $rid
+ Subscription = if ($subId -and $subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { '' }
+ Cost = 0.0
+ }
+ }
+ $agg[$key].Cost += $cost
+ }
+
+ # Linear month-to-date projection so the per-resource forecast matches the
+ # subscription-level export forecast (ConvertTo-CostDataFromExport). Without
+ # this, Forecast == Actual (MTD) and downstream views (e.g. AHB "With AHB
+ # (Mo.)") show a flat month-to-date number instead of a month-end projection.
+ $now = Get-Date
+ $daysInMo = [DateTime]::DaysInMonth($now.Year, $now.Month)
+ $dayOfMo = [math]::Max(1, $now.Day)
+
+ foreach ($v in $agg.Values) {
+ $c = [math]::Round($v.Cost, 2)
+ $fc = [math]::Round($c / $dayOfMo * $daysInMo, 2)
+ [void]$out.Add([PSCustomObject]@{
+ Subscription = $v.Subscription
+ ResourceGroup = $v.ResourceGroup
+ ResourceType = $v.ResourceType
+ ResourcePath = $v.ResourcePath
+ Actual = $c
+ Forecast = $fc
+ Currency = $ExportData.Currency
+ })
+ }
+ return @($out | Sort-Object Actual -Descending)
+}
+
+# -- Converter: export rows -> Get-CostByTag result -----------------------
+function ConvertTo-CostByTagFromExport {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][object]$ExportData,
+ [hashtable]$ExistingTags = @{}
+ )
+ $cm = $ExportData.ColMap
+ $results = @{}
+ if (-not $cm -or -not $cm.Cost -or -not $cm.Tags) {
+ return [PSCustomObject]@{ TagsQueried = @(); CostByTag = $results; NoTagsFound = $true; UsedTimeframe = 'Export' }
+ }
+
+ # tagKey -> ( tagValue -> cost )
+ $byKey = @{}
+ foreach ($r in $ExportData.Rows) {
+ $raw = "$($r.$($cm.Tags))"
+ if ([string]::IsNullOrWhiteSpace($raw)) { continue }
+ $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null
+ if ($cost -eq 0) { continue }
+ $tags = ConvertFrom-ExportTagString -Raw $raw
+ foreach ($tk in $tags.Keys) {
+ $tv = $tags[$tk]
+ if (-not $byKey.ContainsKey($tk)) { $byKey[$tk] = @{} }
+ if (-not $byKey[$tk].ContainsKey($tv)) { $byKey[$tk][$tv] = 0.0 }
+ $byKey[$tk][$tv] += $cost
+ }
+ }
+
+ foreach ($tk in $byKey.Keys) {
+ $vals = foreach ($tv in $byKey[$tk].Keys) {
+ [PSCustomObject]@{
+ TagValue = $tv
+ Cost = [math]::Round($byKey[$tk][$tv], 2)
+ Currency = $ExportData.Currency
+ }
+ }
+ $results[$tk] = @($vals | Sort-Object Cost -Descending)
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = @($byKey.Keys)
+ CostByTag = $results
+ NoTagsFound = ($byKey.Count -eq 0)
+ UsedTimeframe = 'Export'
+ }
+}
+
+# -- Converter: export rows -> Get-CostTrend result -----------------------
+# A single export run usually covers the current billing month; trend will
+# show whatever months the export's date range contains.
+function ConvertTo-CostTrendFromExport {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][object]$ExportData)
+
+ $cm = $ExportData.ColMap
+ $months = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $bySub = @{}
+ if (-not $cm -or -not $cm.Cost -or -not $cm.Date) {
+ return [PSCustomObject]@{ Months = @(); BySubscription = $bySub; HasData = $false }
+ }
+
+ $agg = @{} # yyyy-MM -> @{ Cost; Date }
+ $subAgg = @{} # subId -> ( yyyy-MM -> @{ Cost; Date } )
+ foreach ($r in $ExportData.Rows) {
+ $dt = $null
+ try { $dt = [datetime]"$($r.$($cm.Date))" } catch { continue }
+ $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null
+ $firstOfMo = Get-Date -Year $dt.Year -Month $dt.Month -Day 1 -Hour 0 -Minute 0 -Second 0
+ $key = $dt.ToString('yyyy-MM')
+
+ if (-not $agg.ContainsKey($key)) { $agg[$key] = @{ Cost = 0.0; Date = $firstOfMo } }
+ $agg[$key].Cost += $cost
+
+ if ($cm.SubscriptionId) {
+ $subId = Get-GuidFromString -Value "$($r.$($cm.SubscriptionId))"
+ if ($subId) {
+ if (-not $subAgg.ContainsKey($subId)) { $subAgg[$subId] = @{} }
+ if (-not $subAgg[$subId].ContainsKey($key)) { $subAgg[$subId][$key] = @{ Cost = 0.0; Date = $firstOfMo } }
+ $subAgg[$subId][$key].Cost += $cost
+ }
+ }
+ }
+
+ foreach ($entry in $agg.GetEnumerator() | Sort-Object Key) {
+ [void]$months.Add([PSCustomObject]@{
+ Month = $entry.Value.Date.ToString('MMM yyyy')
+ MonthDate = $entry.Value.Date
+ Cost = [math]::Round($entry.Value.Cost, 2)
+ Currency = $ExportData.Currency
+ })
+ }
+
+ foreach ($subId in $subAgg.Keys) {
+ $list = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($entry in $subAgg[$subId].GetEnumerator() | Sort-Object Key) {
+ [void]$list.Add([PSCustomObject]@{
+ Month = $entry.Value.Date.ToString('MMM yyyy')
+ MonthDate = $entry.Value.Date
+ Cost = [math]::Round($entry.Value.Cost, 2)
+ Currency = $ExportData.Currency
+ })
+ }
+ $bySub[$subId] = @($list | Sort-Object MonthDate)
+ }
+
+ $sorted = @($months | Sort-Object MonthDate)
+ return [PSCustomObject]@{
+ Months = $sorted
+ BySubscription = $bySub
+ HasData = ($sorted.Count -gt 0)
+ }
+}
+
+# -- Read + merge the newest CSV data across several exports ---------------
+# Reads each export's newest run and concatenates the normalized rows into a
+# single ExportData object. When more than one export covers the same
+# subscription, only the export with the newest run date for that sub is
+# kept, so overlapping exports do not double-count cost. Returns the same
+# shape as Get-CostExportData (Rows / ColMap / DataDate / Currency).
+function Get-MergedCostExportData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][object[]]$Exports,
+ [string]$Environment = 'AzureCloud'
+ )
+
+ # Dedupe by subscription: keep the newest-run export per SubId so two
+ # exports covering the same subscription do not double-count.
+ $bestBySub = @{}
+ $noSub = [System.Collections.Generic.List[object]]::new()
+ foreach ($exp in $Exports) {
+ if (-not $exp) { continue }
+ $sid = "$($exp.SubId)"
+ if ([string]::IsNullOrWhiteSpace($sid)) { [void]$noSub.Add($exp); continue }
+ $existing = $bestBySub[$sid]
+ if (-not $existing) { $bestBySub[$sid] = $exp; continue }
+ $a = if ($exp.LastRunDate) { [datetime]$exp.LastRunDate } else { [datetime]::MinValue }
+ $b = if ($existing.LastRunDate) { [datetime]$existing.LastRunDate } else { [datetime]::MinValue }
+ if ($a -gt $b) { $bestBySub[$sid] = $exp }
+ }
+ $chosen = @($bestBySub.Values) + @($noSub)
+
+ $allRows = [System.Collections.Generic.List[object]]::new()
+ $colMap = $null
+ $headers = @()
+ $currency = 'USD'
+ $dataDate = $null
+ $readAny = $false
+
+ foreach ($exp in $chosen) {
+ $data = Get-CostExportData -Export $exp -Environment $Environment
+ if (-not $data -or $data.NoData -or -not $data.Rows -or @($data.Rows).Count -eq 0) { continue }
+ $readAny = $true
+ if (-not $colMap -and $data.ColMap) { $colMap = $data.ColMap; $headers = $data.Headers }
+ if ($data.Currency) { $currency = $data.Currency }
+ if ($data.DataDate -and (-not $dataDate -or [datetime]$data.DataDate -gt $dataDate)) { $dataDate = [datetime]$data.DataDate }
+ foreach ($r in $data.Rows) { [void]$allRows.Add($r) }
+ }
+
+ return [PSCustomObject]@{
+ Rows = $allRows
+ ColMap = $colMap
+ DataDate = $dataDate
+ Currency = $currency
+ RowCount = $allRows.Count
+ Headers = $headers
+ NoCostColumn = ($colMap -and -not $colMap.Cost)
+ NoData = (-not $readAny -or $allRows.Count -eq 0)
+ ExportCount = @($chosen).Count
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1
new file mode 100644
index 000000000..b2a0b9143
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1
@@ -0,0 +1,346 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-FOHUBPROVIDER.PS1
+# FINOPS HUB - SCALABLE DATA PROVIDER (KUSTO-FIRST)
+###########################################################################
+# Purpose: Resolve which FinOps Hub data provider to use and serve the
+# cost-family scans by pushing aggregation into the Kusto engine,
+# returning ONLY summarized results. This is the scalable hub path
+# for large customer datasets (tens of GB / hundreds of millions of
+# rows) that must never be loaded into PowerShell objects.
+# Date: Created for FinOps Multitool scalable hub data path
+#
+# Description:
+# Provider selection (same code path serves all three):
+# 1. Explicit override - FINOPS_HUB_KUSTO_URI (+ FINOPS_HUB_KUSTO_DB).
+# Covers the offline ftklocal Kusto emulator (anonymous) AND a user-
+# pinned ADX/Fabric cluster. Mirrors the toolkit's "use a provided
+# cluster URI" connect option.
+# 2. Online discovery - Azure Resource Graph for the hub's ADX cluster
+# (microsoft.kusto/clusters tagged ftk-tool == 'FinOps hubs'), exactly
+# the query the FinOps Toolkit's own ftk-hubs-connect flow uses.
+# 3. None - no cluster; caller falls back to the storage
+# export reader (small-dataset path).
+#
+# Each intent (Get-FOHubCostSummary / Get-FOHubResourceCosts /
+# Get-FOHubCostByTag) builds a KQL summarize against the Hub database's
+# versioned cost function (Costs - the latest-version alias the toolkit
+# exposes) and returns the SAME shape the storage converters
+# (ConvertTo-*FromHub) produce, so the cost tools are unchanged.
+#
+# Cost parity: the storage converters treat a 0 BilledCost as falsy and fall
+# through to EffectiveCost. The KQL below replicates that exact coalescing so
+# the Kusto path and the storage path return identical numbers.
+#
+# ── Functions ───────────────────────────────────────────────────
+# Resolve-FOHubProvider Decide provider (override | discovered | none)
+# Get-FOHubCostSummary -> @{ subId = @{ Actual; Forecast; Currency } }
+# Get-FOHubResourceCosts -> @(PSCustomObject Subscription/RG/Type/Path/...)
+# Get-FOHubCostByTag -> @{ TagsQueried; CostByTag; NoTagsFound; ... }
+#
+# Prerequisites:
+# - Invoke-FOHubKustoQuery.ps1, Get-PlainAccessToken.ps1, Search-AzGraphSafe.ps1
+#
+# Usage:
+# $p = Resolve-FOHubProvider -Subscriptions $subs
+# if ($p.Found) { $cost = Get-FOHubCostSummary -Provider $p }
+###########################################################################
+
+# -- Shared KQL snippets --------------------------------------------------
+# Replicates the storage converter's cost selection: BilledCost unless it is
+# null or 0, in which case EffectiveCost (matches `if ($row.BilledCost)`).
+$script:FOHubCostExpr = 'todouble(iff(isnull(BilledCost) or BilledCost == 0, EffectiveCost, BilledCost))'
+
+function Get-FOHubAnchorLet {
+ # Anchor the reporting window to the latest month that actually has data
+ # (max ChargePeriodStart), not the calendar month. On a live hub the latest
+ # month IS the current month, so this matches the storage reader; on stale or
+ # historical data (e.g. a demo/ftklocal dataset) it still returns the newest
+ # available months instead of an empty calendar-month window.
+ return 'let _anchor = toscalar(Costs | summarize x = startofmonth(max(ChargePeriodStart)) | project x);'
+}
+
+function Get-FOHubWindowClause {
+ # Trailing N calendar months ending at the latest data month (_anchor).
+ param([int]$Months = 1)
+ $back = [math]::Max(0, $Months - 1)
+ return "| where ChargePeriodStart >= datetime_add('month', -$back, _anchor)"
+}
+
+function Get-FOHubScopeClause {
+ # Restrict to specific subscriptions (FOCUS SubAccountId is
+ # /subscriptions/{guid}). Only valid GUIDs are injected (no KQL injection).
+ param([string[]]$SubscriptionIds)
+ $guids = @($SubscriptionIds | Where-Object { $_ -match '^[0-9a-fA-F-]{36}$' } | ForEach-Object { $_.ToLower() })
+ if ($guids.Count -eq 0) { return '' }
+ $arr = ($guids | ForEach-Object { '"' + $_ + '"' }) -join ', '
+ return "| where tolower(SubAccountId) has_any (dynamic([$arr]))"
+}
+
+# -- Private: run a query through the resolved provider --------------------
+function Invoke-FOHubProviderQuery {
+ param(
+ [Parameter(Mandatory)][hashtable]$Provider,
+ [Parameter(Mandatory)][string]$Query
+ )
+ $token = $null
+ if ($Provider.UseAuth) {
+ try { $token = Get-PlainAccessToken -ResourceUrl $Provider.ClusterUri }
+ catch { return @{ Ok = $false; Rows = @(); RowCount = 0; Error = "Could not acquire a Kusto token for $($Provider.ClusterUri): $($_.Exception.Message)" } }
+ }
+ return Invoke-FOHubKustoQuery -ClusterUri $Provider.ClusterUri -Database $Provider.Database -Query $Query -AccessToken $token
+}
+
+# -- Provider resolution --------------------------------------------------
+function Resolve-FOHubProvider {
+ [CmdletBinding()]
+ param(
+ [string[]]$Subscriptions,
+ [object]$Decision
+ )
+
+ # 1. Explicit override (ftklocal emulator or a pinned ADX/Fabric cluster).
+ if (-not [string]::IsNullOrWhiteSpace($env:FINOPS_HUB_KUSTO_URI)) {
+ $uri = $env:FINOPS_HUB_KUSTO_URI.Trim()
+ $db = if ($env:FINOPS_HUB_KUSTO_DB) { $env:FINOPS_HUB_KUSTO_DB.Trim() } else { 'Hub' }
+ $isLocal = $uri -match '^https?://(localhost|127\.0\.0\.1|\[::1\])(:|/|$)'
+ return @{
+ Found = $true
+ Mode = if ($isLocal) { 'KustoLocal' } else { 'Kusto' }
+ ClusterUri = $uri
+ Database = $db
+ UseAuth = (-not $isLocal)
+ HubVersion = $null
+ Source = 'EnvOverride'
+ }
+ }
+
+ # 2. A cluster already discovered by Resolve-CostDataSource.
+ if ($Decision -and $Decision.KustoClusterUri) {
+ return @{
+ Found = $true
+ Mode = 'Kusto'
+ ClusterUri = [string]$Decision.KustoClusterUri
+ Database = if ($Decision.KustoDatabase) { [string]$Decision.KustoDatabase } else { 'Hub' }
+ UseAuth = $true
+ HubVersion = $Decision.HubVersion
+ Source = 'Discovered'
+ }
+ }
+
+ # 3. Online discovery via Resource Graph (the toolkit's own connect query).
+ try {
+ $clusterQuery = @"
+resources
+| where type =~ 'microsoft.kusto/clusters'
+| where tags['ftk-tool'] == 'FinOps hubs'
+| extend hubVersion = tostring(tags['ftk-version'])
+| project clusterUri = tostring(properties.uri), hubVersion, resourceGroup, subscriptionId
+| take 1
+"@
+ $res = Search-AzGraphSafe -Query $clusterQuery -Subscription @($Subscriptions) -First 1
+ if ($res -and $res.Data -and @($res.Data).Count -gt 0) {
+ $row = @($res.Data)[0]
+ if ($row.clusterUri) {
+ return @{
+ Found = $true
+ Mode = 'Kusto'
+ ClusterUri = [string]$row.clusterUri
+ Database = 'Hub'
+ UseAuth = $true
+ HubVersion = $row.hubVersion
+ Source = 'Discovered'
+ }
+ }
+ }
+ }
+ catch {
+ # Discovery failed - fall through to None (storage fallback).
+ }
+
+ return @{ Found = $false; Mode = 'None'; ClusterUri = $null; Database = $null; UseAuth = $false; HubVersion = $null; Source = 'None' }
+}
+
+# -- Intent: cost by subscription (matches ConvertTo-CostDataFromHub) ------
+function Get-FOHubCostSummary {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][hashtable]$Provider,
+ [string[]]$SubscriptionIds,
+ [int]$Months = 1
+ )
+ $window = Get-FOHubWindowClause -Months $Months
+ $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds
+ $query = @"
+$(Get-FOHubAnchorLet)
+Costs
+$window
+$scope
+| extend _sub = extract('([0-9a-fA-F-]{36})', 1, tolower(SubAccountId))
+| extend _cost = $($script:FOHubCostExpr)
+| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency), Name = take_any(SubAccountName) by _sub
+"@
+ $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query
+ if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } }
+
+ $costMap = @{}
+ foreach ($row in $r.Rows) {
+ $subId = if ($row._sub) { [string]$row._sub } else { 'unknown' }
+ $currency = if ($row.Currency) { [string]$row.Currency } else { 'USD' }
+ # Carry the subscription's display name from the FOCUS data so the UI can
+ # show a friendly name even for subscriptions that aren't in the caller's
+ # selected list (a hub commonly covers more subs than are being scanned).
+ $subName = if ($row.Name) { [string]$row.Name } else { '' }
+ $costMap[$subId] = @{
+ Actual = [math]::Round([double]$row.Actual, 2)
+ Forecast = 0.0
+ Currency = $currency
+ Name = $subName
+ }
+ }
+ return $costMap
+}
+
+# -- Intent: top resources by cost (matches ConvertTo-ResourceCostsFromHub) -
+function Get-FOHubResourceCosts {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][hashtable]$Provider,
+ [string[]]$SubscriptionIds,
+ [int]$Months = 1,
+ [int]$Top = 500
+ )
+ $window = Get-FOHubWindowClause -Months $Months
+ $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds
+ $query = @"
+$(Get-FOHubAnchorLet)
+Costs
+$window
+$scope
+| extend _cost = $($script:FOHubCostExpr)
+| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency)
+ by Subscription = SubAccountName, ResourceGroup = x_ResourceGroupName, ResourceType, ResourcePath = ResourceId
+| order by Actual desc
+| take $Top
+"@
+ $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query
+ if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } }
+
+ $out = foreach ($row in $r.Rows) {
+ [PSCustomObject]@{
+ Subscription = if ($row.Subscription) { [string]$row.Subscription } else { 'unknown' }
+ ResourceGroup = if ($row.ResourceGroup) { [string]$row.ResourceGroup } else { 'unknown' }
+ ResourceType = if ($row.ResourceType) { [string]$row.ResourceType } else { 'unknown' }
+ ResourcePath = [string]$row.ResourcePath
+ Actual = [math]::Round([double]$row.Actual, 2)
+ Forecast = 0.0
+ Currency = if ($row.Currency) { [string]$row.Currency } else { 'USD' }
+ }
+ }
+ # Sort in PowerShell too (matches ConvertTo-ResourceCostsFromHub and is
+ # robust even if the transport ever returns rows out of engine order).
+ return @($out | Sort-Object -Property Actual -Descending)
+}
+
+# -- Intent: cost by tag (matches ConvertTo-CostByTagFromHub) --------------
+function Get-FOHubCostByTag {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][hashtable]$Provider,
+ [string[]]$SubscriptionIds,
+ [int]$Months = 1,
+ [string[]]$TagKeys
+ )
+ $window = Get-FOHubWindowClause -Months $Months
+ $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds
+
+ # Discover tag keys when the caller didn't supply a set to report on.
+ $keys = @($TagKeys | Where-Object { $_ })
+ if ($keys.Count -eq 0) {
+ $keyQuery = @"
+$(Get-FOHubAnchorLet)
+Costs
+$window
+$scope
+| mv-expand k = bag_keys(Tags) to typeof(string)
+| distinct k
+| take 200
+"@
+ $kr = Invoke-FOHubProviderQuery -Provider $Provider -Query $keyQuery
+ if (-not $kr.Ok) { return @{ Error = $kr.Error; Source = 'Kusto' } }
+ $keys = @($kr.Rows | ForEach-Object { [string]$_.k } | Where-Object { $_ })
+ }
+
+ if ($keys.Count -eq 0) {
+ return [PSCustomObject]@{
+ TagsQueried = @()
+ CostByTag = @{}
+ NoTagsFound = $true
+ UsedTimeframe = 'Hub query period'
+ Source = 'Kusto'
+ }
+ }
+
+ # One snapshot: a sentinel *TOTAL* row plus per-(key,value) cost. Untagged
+ # cost per key is derived in PowerShell as total minus the key's tagged sum
+ # (mirrors the converter assigning '(untagged)' to rows lacking the key).
+ # Escape backslash before quote, matching ConvertTo-KqlLiteral, so a tag key
+ # ending in a backslash cannot terminate the KQL string early.
+ $keyList = ($keys | Where-Object { $_ } | ForEach-Object { '"' + $_.Replace('\', '\\').Replace('"', '\"') + '"' }) -join ', '
+ $query = @"
+$(Get-FOHubAnchorLet)
+let src = Costs
+$window
+$scope
+| extend _cost = $($script:FOHubCostExpr);
+let total = src | summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) | extend TagKey = '*TOTAL*', TagValue = '*TOTAL*';
+let perTag = src
+| mv-expand k = bag_keys(Tags) to typeof(string)
+| where k in ($keyList)
+| extend TagValue = tostring(Tags[k])
+| summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) by TagKey = k, TagValue;
+union total, perTag
+"@
+ $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query
+ if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } }
+
+ $currency = 'USD'
+ $total = 0.0
+ $tagged = @{} # key -> @{ value -> cost }
+ foreach ($row in $r.Rows) {
+ $tk = [string]$row.TagKey
+ $cost = [double]$row.Cost
+ if ($row.Currency) { $currency = [string]$row.Currency }
+ if ($tk -eq '*TOTAL*') { $total = $cost; continue }
+ if (-not $tagged.ContainsKey($tk)) { $tagged[$tk] = @{} }
+ $tv = if ($null -ne $row.TagValue -and "$($row.TagValue)" -ne '') { [string]$row.TagValue } else { '(empty)' }
+ if (-not $tagged[$tk].ContainsKey($tv)) { $tagged[$tk][$tv] = 0.0 }
+ $tagged[$tk][$tv] += $cost
+ }
+
+ $costByTagOut = @{}
+ foreach ($key in $keys) {
+ $values = if ($tagged.ContainsKey($key)) { $tagged[$key] } else { @{} }
+ $taggedSum = 0.0
+ foreach ($v in $values.Values) { $taggedSum += $v }
+ $untagged = [math]::Round($total - $taggedSum, 2)
+
+ $entries = @($values.GetEnumerator() | ForEach-Object {
+ [PSCustomObject]@{ TagValue = $_.Key; Cost = [math]::Round($_.Value, 2); Currency = $currency }
+ })
+ if ($untagged -gt 0) {
+ $entries += [PSCustomObject]@{ TagValue = '(untagged)'; Cost = $untagged; Currency = $currency }
+ }
+ $costByTagOut[$key] = @($entries | Sort-Object Cost -Descending)
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = @($costByTagOut.Keys)
+ CostByTag = $costByTagOut
+ NoTagsFound = ($costByTagOut.Count -eq 0)
+ UsedTimeframe = 'Hub query period'
+ Source = 'Kusto'
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1
new file mode 100644
index 000000000..538bb2bf6
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1
@@ -0,0 +1,384 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-KPIINSIGHTS.PS1
+# FINOPS KPI CORRELATION LAYER
+###########################################################################
+# Purpose: Map FinOps Multitool scan output to FinOps Foundation KPIs
+# (https://www.finops.org/finops-kpis/) so callers who do not
+# know the KPI taxonomy still see which industry KPIs their results
+# inform, with a computed value where the data allows.
+# Date: Created for KPI skills
+#
+# Description:
+# Additive only. Does not change any scan. After a scan returns, the
+# server calls Add-KpiInsights to attach a kpiInsights[] block:
+# - status 'computed' a value was derived from the scan fields
+# - status 'informational' the scan relates to the KPI; explore to learn
+# Two public entry points:
+# Add-KpiInsights enrich a tool result in place (server-side)
+# Get-KpiExploration browse the catalog (explore_finops_kpis tool)
+#
+# Usage: dot-sourced by FinOpsMultitool.psm1
+###########################################################################
+
+$script:KpiCatalog = $null
+
+# Canonical CAF allocation (chargeback/showback) tag dimensions. Cost-allocation
+# coverage is measured ONLY against these - not identity/marker tags (FinOps,
+# cm-resource-parent, tag1, managedBy, CreatedByPolicy, ...) that blanket
+# resources and would give a misleading untagged figure. Single source of truth
+# shared by the KPI compute and the TUI cost-by-tag guidance so they agree.
+function Get-CafAllocationTag {
+ return @('CostCenter', 'Customer', 'Project', 'Environment', 'Application',
+ 'Owner', 'BusinessUnit', 'Department', 'Team', 'Service', 'WorkloadName')
+}
+
+function Get-KpiCatalog {
+ if ($script:KpiCatalog) { return $script:KpiCatalog }
+ # kpi-catalog.json lives in ../kpi relative to modules/helpers
+ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+ $path = Join-Path (Join-Path $root 'kpi') 'kpi-catalog.json'
+ if (-not (Test-Path $path)) {
+ # Fall back to ScriptRootDir if structure differs
+ if ($script:ScriptRootDir) {
+ $path = Join-Path (Join-Path $script:ScriptRootDir 'kpi') 'kpi-catalog.json'
+ }
+ }
+ if (-not (Test-Path $path)) { return $null }
+ $script:KpiCatalog = Get-Content $path -Raw | ConvertFrom-Json
+ return $script:KpiCatalog
+}
+
+# Pull a property value off a scan-result object whether it is the object
+# itself or wrapped in a .data property (server wrapper).
+function Get-ScanField {
+ param($Data, [string]$Name)
+ if ($null -eq $Data) { return $null }
+ if ($Data.PSObject.Properties[$Name]) { return $Data.$Name }
+ if ($Data.PSObject.Properties['data'] -and $Data.data.PSObject.Properties[$Name]) { return $Data.data.$Name }
+ return $null
+}
+
+# Compute a KPI value from scan data where we have a real formula. Returns
+# a string value or $null when it cannot be computed (stays informational).
+function Get-KpiComputedValue {
+ param([string]$KpiId, $Data, $Catalog)
+
+ switch ($KpiId) {
+ 'cost-per-gb-stored' {
+ $v = Get-ScanField $Data 'CostPerGb'
+ $cur = Get-ScanField $Data 'Currency'
+ if ($null -ne $v -and $v -gt 0) { return "$cur $v per GB / month" }
+ }
+ 'hourly-cost-per-cpu-core' {
+ $v = Get-ScanField $Data 'CostPerVCpu'
+ $cur = Get-ScanField $Data 'Currency'
+ if ($null -ne $v -and $v -gt 0) {
+ $hourly = [math]::Round([double]$v / 730, 4)
+ return "$cur $hourly per vCPU / hour"
+ }
+ }
+ 'effective-avg-compute-cost-per-core' {
+ $v = Get-ScanField $Data 'CostPerVCpu'
+ $cur = Get-ScanField $Data 'Currency'
+ if ($null -ne $v -and $v -gt 0) { return "$cur $v per vCPU / month" }
+ }
+ 'commitment-utilization-score' {
+ $ri = Get-ScanField $Data 'RIAvgUtilization'
+ $sp = Get-ScanField $Data 'SPAvgUtilization'
+ $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 }
+ if ($vals.Count -gt 0) {
+ $avg = [math]::Round(($vals | Measure-Object -Average).Average, 1)
+ return "$avg%"
+ }
+ }
+ 'anomaly-detection-rate' {
+ # No true rate is possible (Azure does not expose how many anomalies
+ # actually occurred, only what it caught). Report an honest PROXY:
+ # anomaly alerts triggered + detection rules configured. Both are
+ # countable. Returns $null only when neither field is present.
+ $anom = Get-ScanField $Data 'AnomalyAlertCount'
+ $rules = Get-ScanField $Data 'ConfiguredRuleCount'
+ if ($null -ne $anom -or $null -ne $rules) {
+ $a = if ($null -ne $anom) { [int]$anom } else { 0 }
+ $r = if ($null -ne $rules) { [int]$rules } else { 0 }
+ $alertWord = if ($a -eq 1) { 'alert' } else { 'alerts' }
+ $ruleWord = if ($r -eq 1) { 'rule' } else { 'rules' }
+ return "$a anomaly $alertWord caught, $r detection $ruleWord configured (proxy)"
+ }
+ }
+ 'percent-unused-resources' {
+ # No per-orphan cost in the scan, so report the orphaned-resource
+ # count (still a concrete waste signal).
+ $n = Get-ScanField $Data 'TotalCount'
+ if ($null -ne $n) {
+ $word = if ([int]$n -eq 1) { 'orphaned resource' } else { 'orphaned resources' }
+ return "$([int]$n) $word"
+ }
+ }
+ 'computational-waste' {
+ # Share of running VMs flagged idle/underutilized.
+ $idle = Get-ScanField $Data 'Count'
+ $scanned = Get-ScanField $Data 'ScannedVMs'
+ if ($null -ne $idle -and $null -ne $scanned -and [int]$scanned -gt 0) {
+ $pct = [math]::Round(100 * [int]$idle / [int]$scanned, 1)
+ return "$pct% of running VMs idle ($([int]$idle) of $([int]$scanned))"
+ }
+ }
+ 'budget-burn-rate' {
+ # Average percent of budget consumed across all budgets.
+ $budgets = Get-ScanField $Data 'Budgets'
+ if ($budgets) {
+ $pcts = @($budgets | ForEach-Object { $_.PctUsed } | Where-Object { $null -ne $_ })
+ if ($pcts.Count -gt 0) {
+ $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1)
+ return "$avg% of budget consumed (avg across $($pcts.Count))"
+ }
+ }
+ }
+ 'variance-budget-vs-actual' {
+ # Total actual vs total budgeted across all budgets.
+ $budgets = Get-ScanField $Data 'Budgets'
+ if ($budgets) {
+ $totBudget = ($budgets | Measure-Object -Property Amount -Sum).Sum
+ $totActual = ($budgets | Measure-Object -Property ActualSpend -Sum).Sum
+ if ($totBudget -and $totBudget -gt 0) {
+ $variance = [math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1)
+ $sign = if ($variance -ge 0) { 'over' } else { 'under' }
+ return "$([math]::Abs($variance))% $sign budget (actual vs planned)"
+ }
+ }
+ }
+ 'effective-savings-rate' {
+ # Realized monthly savings from commitments + AHB (proxy: a true rate
+ # also needs total on-demand-equivalent spend, not in this scan).
+ $monthly = Get-ScanField $Data 'TotalMonthly'
+ $cur = Get-ScanField $Data 'Currency'
+ if (-not $cur) { $cur = 'USD' }
+ if ($null -ne $monthly -and [double]$monthly -gt 0) {
+ return "$cur $([math]::Round([double]$monthly, 2)) / month realized (proxy)"
+ }
+ }
+ 'pct-compute-covered-by-commitment' {
+ # Commitment coverage = committed eligible spend / total eligible
+ # spend (excludes Spot). Computed in Get-SavingsRealized from
+ # amortized cost grouped by pricing model.
+ $cov = Get-ScanField $Data 'CommitmentCoveragePct'
+ $committed = Get-ScanField $Data 'CommittedAmortized'
+ $onDemand = Get-ScanField $Data 'OnDemandAmortized'
+ $cur = Get-ScanField $Data 'Currency'
+ if (-not $cur) { $cur = 'USD' }
+ if ($null -ne $cov) {
+ $detail = ''
+ if ($null -ne $committed -and $null -ne $onDemand) {
+ $base = [double]$committed + [double]$onDemand
+ $detail = " ($cur $([math]::Round([double]$committed, 0)) committed of $cur $([math]::Round($base, 0)) eligible)"
+ }
+ return "$cov% covered by commitments$detail"
+ }
+ }
+ 'token-consumption-metrics' {
+ $tokens = Get-ScanField $Data 'TotalTokens'
+ $cost = Get-ScanField $Data 'TotalAICost'
+ $cur = Get-ScanField $Data 'Currency'
+ if (-not $cur) { $cur = 'USD' }
+ if ($null -ne $tokens -and [long]$tokens -gt 0) {
+ $costStr = if ($null -ne $cost -and [double]$cost -gt 0) { " for $cur $([math]::Round([double]$cost, 2)) (MTD)" } else { '' }
+ return "$('{0:N0}' -f [long]$tokens) tokens$costStr"
+ }
+ }
+ 'cost-per-api-call' {
+ $cpr = Get-ScanField $Data 'CostPerRequest'
+ $cur = Get-ScanField $Data 'Currency'
+ if (-not $cur) { $cur = 'USD' }
+ if ($null -ne $cpr -and [double]$cpr -gt 0) {
+ return "$cur $([math]::Round([double]$cpr, 5)) per AI request"
+ }
+ }
+ 'pct-commitment-discount-waste' {
+ $ri = Get-ScanField $Data 'RIAvgUtilization'
+ $sp = Get-ScanField $Data 'SPAvgUtilization'
+ $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 }
+ if ($vals.Count -gt 0) {
+ $avg = ($vals | Measure-Object -Average).Average
+ return "$([math]::Round(100 - $avg, 1))%"
+ }
+ }
+ { $_ -in @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant') } {
+ # Derive from CostByTag. These KPIs measure ALLOCATION coverage, so
+ # only consider recognized CAF allocation dimensions - not identity
+ # tags (e.g. tag1/tag2) that blanket every resource and would give a
+ # misleading 0% untagged. CostByTag may be a hashtable (hub/export)
+ # or a PSCustomObject (live path).
+ $cbt = Get-ScanField $Data 'CostByTag'
+ if (-not $cbt) { return $null }
+ $allocTags = Get-CafAllocationTag
+ $tagPairs = @()
+ if ($cbt -is [System.Collections.IDictionary]) {
+ foreach ($k in $cbt.Keys) { $tagPairs += [PSCustomObject]@{ Name = $k; Value = $cbt[$k] } }
+ }
+ else {
+ foreach ($prop in $cbt.PSObject.Properties) { $tagPairs += [PSCustomObject]@{ Name = $prop.Name; Value = $prop.Value } }
+ }
+ $best = $null
+ foreach ($tp in $tagPairs) {
+ if ($allocTags -notcontains $tp.Name) { continue } # allocation tags only
+ $rows = @($tp.Value)
+ $total = ($rows | Measure-Object -Property Cost -Sum).Sum
+ if (-not $total -or $total -le 0) { continue }
+ $untag = ($rows | Where-Object { $_.TagValue -eq '(untagged)' } | Measure-Object -Property Cost -Sum).Sum
+ if ($null -eq $untag) { $untag = 0 }
+ $pctUntag = [math]::Round(100 * $untag / $total, 1)
+ if ($null -eq $best -or $pctUntag -lt $best.PctUntag) {
+ $best = [PSCustomObject]@{ Tag = $tp.Name; PctUntag = $pctUntag }
+ }
+ }
+ if ($null -eq $best) { return $null } # no allocation tags -> stays informational
+ switch ($KpiId) {
+ 'pct-costs-untagged' { return "$($best.PctUntag)% untagged (by allocation tag '$($best.Tag)')" }
+ 'pct-costs-unallocated' { return "$($best.PctUntag)% unallocated (by '$($best.Tag)')" }
+ 'tagging-policy-compliant' { return "$([math]::Round(100 - $best.PctUntag, 1))% compliant (by '$($best.Tag)')" }
+ }
+ }
+ }
+ return $null
+}
+
+# Enrich a server tool-result hashtable in place with a kpiInsights array.
+function Add-KpiInsights {
+ param([Parameter(Mandatory)]$Result)
+
+ $catalog = Get-KpiCatalog
+ if (-not $catalog) { return $Result }
+
+ $toolName = $null
+ if ($Result -is [hashtable]) { $toolName = $Result['tool'] }
+ elseif ($Result.PSObject.Properties['tool']) { $toolName = $Result.tool }
+ if (-not $toolName) { return $Result }
+
+ $matches = @($catalog.kpis | Where-Object { $_.sourceTool -eq $toolName })
+ if ($matches.Count -eq 0) { return $Result }
+
+ $data = if ($Result -is [hashtable]) { $Result['data'] } else { $Result.data }
+
+ $insights = @()
+ foreach ($kpi in $matches) {
+ $value = $null
+ if ($kpi.compute) { $value = Get-KpiComputedValue -KpiId $kpi.id -Data $data -Catalog $catalog }
+ $status = if ($value) { 'computed' } else { 'informational' }
+ $insights += [PSCustomObject]@{
+ kpiId = $kpi.id
+ kpiName = $kpi.name
+ domain = $kpi.domain
+ status = $status
+ yourValue = $value
+ plainLanguage = $kpi.plainLanguage
+ exploreHint = $kpi.exploreHint
+ learnMore = $catalog.learnMoreBase
+ }
+ }
+
+ if ($insights.Count -gt 0) {
+ if ($Result -is [hashtable]) { $Result['kpiInsights'] = @($insights) }
+ else { $Result | Add-Member -NotePropertyName 'kpiInsights' -NotePropertyValue @($insights) -Force }
+ }
+ return $Result
+}
+
+# Map a raw scan function name (as used by the TUI/automated editions) to the
+# scan name the KPI catalog keys off (sourceTool). Lets every caller reuse the
+# exact same compute path, so KPI behavior stays in parity.
+function Get-KpiToolNameForFunction {
+ param([Parameter(Mandatory)][string]$FunctionName)
+ $map = @{
+ 'Get-UnitEconomics' = 'scan_unit_economics'
+ 'Get-CostByTag' = 'scan_cost_by_tag'
+ 'Get-CommitmentUtilization' = 'scan_commitment_utilization'
+ 'Get-ReservationAdvice' = 'scan_reservation_advice'
+ 'Get-OrphanedResources' = 'scan_orphaned_resources'
+ 'Get-IdleVMs' = 'scan_idle_vms'
+ 'Get-StorageTierAdvice' = 'scan_storage_tier_advice'
+ 'Get-BudgetStatus' = 'scan_budget_status'
+ 'Get-AnomalyAlerts' = 'scan_anomaly_alerts'
+ 'Get-SavingsRealized' = 'scan_savings_realized'
+ 'Get-LegacyResources' = 'scan_legacy_resources'
+ 'Get-CarbonMetrics' = 'scan_carbon'
+ 'Get-AIWorkloadMetrics' = 'scan_ai_workloads'
+ 'Get-CostTrend' = 'scan_cost_trend'
+ 'Get-ResourceCosts' = 'scan_resource_costs'
+ 'Get-VmCostBreakdown' = 'scan_vm_cost_breakdown'
+ 'Get-SharedCostAllocation' = 'scan_allocate_shared_cost'
+ 'Get-UsageProportionalAllocation' = 'scan_usage_allocation'
+ }
+ if ($map.ContainsKey($FunctionName)) { return $map[$FunctionName] }
+ return $null
+}
+
+# Compute the kpiInsights array for a raw scan output (where the result IS the
+# data, not a { tool; data } envelope). Wraps the output in the same
+# envelope the catalog expects so Add-KpiInsights/Get-KpiComputedValue run the
+# identical logic. Returns an array of insight objects (possibly empty).
+function Get-KpiInsightsForResult {
+ param(
+ [Parameter(Mandatory)][string]$FunctionName,
+ $Output
+ )
+ if ($null -eq $Output) { return @() }
+ $toolName = Get-KpiToolNameForFunction -FunctionName $FunctionName
+ if (-not $toolName) { return @() }
+ $envelope = @{ tool = $toolName; data = $Output }
+ $enriched = Add-KpiInsights -Result $envelope
+ if ($enriched -is [System.Collections.IDictionary] -and $enriched.Contains('kpiInsights')) {
+ return @($enriched['kpiInsights'])
+ }
+ return @()
+}
+
+# Browse the KPI catalog for the explore_finops_kpis tool.
+function Get-KpiExploration {
+ param([string]$KpiId)
+
+ $catalog = Get-KpiCatalog
+ if (-not $catalog) { return @{ error = 'KPI catalog not found.' } }
+
+ if ($KpiId) {
+ $kpi = $catalog.kpis | Where-Object { $_.id -eq $KpiId } | Select-Object -First 1
+ if (-not $kpi) { return @{ error = "Unknown KPI id '$KpiId'. Call explore_finops_kpis with no id to list all." } }
+ return @{
+ kpi = [PSCustomObject]@{
+ id = $kpi.id
+ name = $kpi.name
+ domain = $kpi.domain
+ definition = $kpi.definition
+ sourceTool = $kpi.sourceTool
+ computable = [bool]$kpi.compute
+ plainLanguage = $kpi.plainLanguage
+ exploreHint = $kpi.exploreHint
+ learnMore = $catalog.learnMoreBase
+ }
+ howTo = "Run $($kpi.sourceTool) to inform this KPI. $(if ($kpi.compute) { 'The server computes a value from the scan.' } else { 'The scan relates to this KPI; use the explore hint to dig in.' })"
+ }
+ }
+
+ # No id: list grouped by domain with computable flag
+ $byDomain = @{}
+ foreach ($kpi in $catalog.kpis) {
+ $d = $kpi.domain
+ if (-not $byDomain.ContainsKey($d)) { $byDomain[$d] = @() }
+ $byDomain[$d] += [PSCustomObject]@{
+ id = $kpi.id
+ name = $kpi.name
+ sourceTool = $kpi.sourceTool
+ status = if ($kpi.compute) { 'Computable now' } else { 'Informational (run the tool)' }
+ }
+ }
+ return @{
+ catalogVersion = $catalog.version
+ totalKpis = @($catalog.kpis).Count
+ learnMore = $catalog.learnMoreBase
+ note = 'These FinOps Foundation KPIs can be informed by this server today. Run the listed tool, then read the kpiInsights block it returns. More KPIs will be added over time.'
+ byDomain = $byDomain
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1
new file mode 100644
index 000000000..f4b90d576
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1
@@ -0,0 +1,23 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# ARM endpoint for the cloud the user is actually signed in to. Hardcoding the
+# public URL breaks Azure Government and Azure China.
+function Get-FinOpsArmEndpoint {
+ $url = $null
+ try { $url = (Get-AzContext).Environment.ResourceManagerUrl } catch { }
+ if ([string]::IsNullOrWhiteSpace($url)) { $url = 'https://management.azure.com' }
+ return $url.TrimEnd('/')
+}
+
+function Get-PlainAccessToken {
+ param([string]$ResourceUrl)
+ if ([string]::IsNullOrWhiteSpace($ResourceUrl)) { $ResourceUrl = Get-FinOpsArmEndpoint }
+ $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token
+ if ($tok -is [securestring]) {
+ $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok)
+ try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }
+ finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
+ }
+ else { $tok }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1
new file mode 100644
index 000000000..5541da051
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1
@@ -0,0 +1,187 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# -- Shared Runspace Pool --------------------------------------------------
+# Created once at module load. Reused by Invoke-AzRestMethodWithRetry and
+# Search-AzGraphSafe to avoid the ~1-2s cold-start per runspace creation.
+if (-not $script:RunspacePool -or $script:RunspacePool.RunspacePoolStateInfo.State -ne 'Opened') {
+ $script:RunspacePool = [runspacefactory]::CreateRunspacePool(1, 6)
+ $script:RunspacePool.Open()
+}
+
+# -- Friendly throttle message ---------------------------------------------
+# While waiting out a 429 rate limit, show a single friendly status instead
+# of a technical "throttled" notice. Shared by all retry paths.
+function Get-NextThrottleMessage {
+ return 'Fetching numbers......'
+}
+
+# -- WPF Detection ---------------------------------------------------------
+# When running standalone (no GUI), skip DispatcherFrame pumping and use
+# simple Start-Sleep instead. This lets the same code work in both contexts.
+function Test-WpfLoaded {
+ try {
+ $dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher
+ return ($null -ne $dispatcher -and
+ -not $dispatcher.HasShutdownStarted -and
+ [System.Windows.Application]::Current -ne $null)
+ }
+ catch { return $false }
+}
+
+function Wait-WithDispatcher {
+ param([int]$Milliseconds)
+ if (Test-WpfLoaded) {
+ $waitEnd = (Get-Date).AddMilliseconds($Milliseconds)
+ while ((Get-Date) -lt $waitEnd) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+ }
+ else {
+ Start-Sleep -Milliseconds $Milliseconds
+ }
+}
+
+function Wait-ForRunspace {
+ param(
+ [System.IAsyncResult]$AsyncResult,
+ [int]$TimeoutSeconds = 60
+ )
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ if (Test-WpfLoaded) {
+ while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+ }
+ else {
+ while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ Start-Sleep -Milliseconds 200
+ }
+ }
+}
+
+function Invoke-AzRestMethodWithRetry {
+ param(
+ [string]$Path,
+ [string]$Method = 'POST',
+ [string]$Payload,
+ [int]$MaxRetries = 3,
+ [int]$TimeoutSeconds = 60
+ )
+ for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($p, $m, $pl)
+ $params = @{ Path = $p; Method = $m; ErrorAction = 'Stop' }
+ if ($pl) { $params['Payload'] = $pl }
+ try {
+ $r = Invoke-AzRestMethod @params
+ $hdrs = @{}
+ if ($r.Headers) {
+ foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] }
+ }
+ [PSCustomObject]@{
+ StatusCode = $r.StatusCode
+ Content = $r.Content
+ Headers = $hdrs
+ }
+ }
+ catch {
+ # A transport-level failure ("An error occurred while sending
+ # the request", TLS/socket drop, token-acquisition error) has
+ # no HTTP status and would otherwise re-throw out of EndInvoke
+ # as a raw ErrorActionPreference=Stop exception. Surface it as
+ # a synthetic 503 so the caller degrades gracefully.
+ $msg = "$($_.Exception.Message)" -replace '[\\"]', "'"
+ [PSCustomObject]@{
+ StatusCode = 503
+ Content = ('{"error":{"message":"' + $msg + '"}}')
+ Headers = @{}
+ }
+ }
+ }).AddArgument($Path).AddArgument($Method).AddArgument($Payload)
+
+ $asyncResult = $ps.BeginInvoke()
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ $resp = $null
+ if ($asyncResult.IsCompleted) {
+ try {
+ $raw = $ps.EndInvoke($asyncResult)
+ $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ }
+ catch {
+ # Should be rare now that the runspace script catches internally,
+ # but never let an EndInvoke failure bubble up as a raw
+ # terminating error - degrade to a synthetic 503 instead.
+ $ps.Dispose()
+ Write-Warning " REST call failed in runspace: $($_.Exception.Message)"
+ return [PSCustomObject]@{
+ StatusCode = 503
+ Content = '{"error":{"message":"Request failed (transport error)."}}'
+ Headers = @{}
+ }
+ }
+ }
+ else {
+ $ps.Stop()
+ Write-Warning " REST call timed out after $($TimeoutSeconds)s: $Method $Path"
+ $ps.Dispose()
+ return [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Request timed out"}}'; Headers = @{} }
+ }
+
+ $ps.Dispose()
+
+ if (-not $resp) {
+ $resp = [PSCustomObject]@{ StatusCode = 0; Content = $null; Headers = @{} }
+ }
+ if ($null -eq $resp.Content) {
+ $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = if ($resp.Headers) { $resp.Headers } else { @{} } }
+ }
+
+ # 429 and transient 5xx are both retryable. 5xx also covers the synthetic
+ # 503 this function raises for transport failures, which are the most
+ # likely thing to succeed on a second attempt.
+ $isThrottled = ($resp.StatusCode -eq 429)
+ $isServerErr = ($resp.StatusCode -ge 500 -and $resp.StatusCode -le 599)
+ if (-not ($isThrottled -or $isServerErr)) { return $resp }
+ if ($attempt -eq $MaxRetries) { return $resp }
+
+ # Parse Retry-After header or default to exponential backoff
+ $retryAfter = 10
+ if ($isThrottled -and $resp.Headers -and $resp.Headers['Retry-After']) {
+ $parsed = 0
+ if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) {
+ $retryAfter = [math]::Max($parsed, 5)
+ }
+ }
+ elseif ($isServerErr) {
+ $retryAfter = [math]::Min(2 * [math]::Pow(2, $attempt), 30)
+ }
+ else {
+ $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60)
+ }
+ $friendly = if ($isThrottled) { Get-NextThrottleMessage } else { "Azure returned $($resp.StatusCode) - retrying..." }
+ Write-Host " $friendly" -ForegroundColor Yellow
+
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus $friendly
+ }
+
+ Wait-WithDispatcher -Milliseconds ($retryAfter * 1000)
+ }
+ return $resp
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1
new file mode 100644
index 000000000..01c85e016
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1
@@ -0,0 +1,126 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# INVOKE-FOHUBKUSTOQUERY.PS1
+# FINOPS HUB - KUSTO (ADX / FABRIC / FTKLOCAL) QUERY TRANSPORT
+###########################################################################
+# Purpose: Execute a KQL query against a FinOps Hub database and return
+# only the (already-summarized) result rows. This is the scalable
+# hub path: aggregation is pushed into the engine so PowerShell
+# never materializes tens of GB / hundreds of millions of rows.
+# Date: Created for FinOps Multitool scalable hub data path
+#
+# Description:
+# Thin REST transport over the Kusto query endpoint (POST {cluster}/v1/rest/query):
+# 1. Works against a deployed Azure Data Explorer / Fabric cluster (with a
+# bearer token) AND a local ftklocal Kusto emulator (anonymous, no token).
+# 2. Sends { db, csl } and parses the v1 response, mapping the primary
+# result table's columns + rows into PSCustomObjects keyed by column name.
+# 3. Returns a small wrapper ({ Ok; Rows; RowCount; Error }) so callers can
+# branch without try/catch. No Az.Kusto module / SDK dependency.
+#
+# ── Parameters ──────────────────────────────────────────────────
+# ClusterUri Cluster query URI (e.g. https://..kusto.windows.net
+# or http://localhost:8082 for the ftklocal emulator)
+# Query KQL query text (csl). Should already aggregate/summarize.
+# Database Hub database name (default 'Hub' - the FinOps Hub cost db)
+# AccessToken Optional bearer token. Omit for an anonymous local emulator.
+# TimeoutSec Per-request timeout (default 120)
+#
+# Prerequisites:
+# - Network reachability to the cluster query endpoint
+# - For a deployed cluster: a token from Get-PlainAccessToken -ResourceUrl
+#
+# Usage:
+# $r = Invoke-FOHubKustoQuery -ClusterUri $uri -Database 'Hub' `
+# -Query 'Costs_v1_2() | summarize Cost=sum(EffectiveCost)'
+# if ($r.Ok) { $r.Rows | ForEach-Object { $_.Cost } }
+###########################################################################
+
+function Invoke-FOHubKustoQuery {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$ClusterUri,
+
+ [Parameter(Mandatory)]
+ [string]$Query,
+
+ [Parameter()]
+ [string]$Database = 'Hub',
+
+ [Parameter()]
+ [string]$AccessToken,
+
+ [Parameter()]
+ [int]$TimeoutSec = 120
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ClusterUri)) {
+ return @{ Ok = $false; Rows = @(); RowCount = 0; Error = 'ClusterUri is required.' }
+ }
+
+ $base = $ClusterUri.TrimEnd('/')
+ $uri = "$base/v1/rest/query"
+
+ $headers = @{
+ 'Content-Type' = 'application/json'
+ 'Accept' = 'application/json'
+ }
+ if (-not [string]::IsNullOrWhiteSpace($AccessToken)) {
+ $headers['Authorization'] = "Bearer $AccessToken"
+ }
+
+ $body = @{ db = $Database; csl = $Query } | ConvertTo-Json -Depth 3
+
+ try {
+ $resp = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $body `
+ -TimeoutSec $TimeoutSec -ErrorAction Stop
+
+ # v1 response: { Tables: [ { TableName, Columns:[{ColumnName,...}], Rows:[[...]] } ] }
+ # The primary result set is the first table.
+ $table = $null
+ if ($resp.Tables -and @($resp.Tables).Count -gt 0) {
+ $table = @($resp.Tables)[0]
+ }
+ if (-not $table) {
+ return @{ Ok = $true; Rows = @(); RowCount = 0; Error = $null }
+ }
+
+ $colNames = @($table.Columns | ForEach-Object { $_.ColumnName })
+ $rows = foreach ($r in @($table.Rows)) {
+ $obj = [ordered]@{}
+ for ($i = 0; $i -lt $colNames.Count; $i++) {
+ $obj[$colNames[$i]] = $r[$i]
+ }
+ [PSCustomObject]$obj
+ }
+ $rows = @($rows)
+
+ return @{ Ok = $true; Rows = $rows; RowCount = $rows.Count; Error = $null }
+ }
+ catch {
+ $msg = $_.Exception.Message
+ # Surface the Kusto error body when present (one-api error envelope).
+ $detail = $null
+ try {
+ $respStream = $_.Exception.Response.GetResponseStream()
+ if ($respStream) {
+ $reader = New-Object System.IO.StreamReader($respStream)
+ $detail = $reader.ReadToEnd()
+ $reader.Dispose()
+ }
+ }
+ catch { }
+ if ($detail) {
+ try {
+ $err = $detail | ConvertFrom-Json -ErrorAction Stop
+ if ($err.error -and $err.error.'@message') { $msg = $err.error.'@message' }
+ elseif ($err.error -and $err.error.message) { $msg = $err.error.message }
+ }
+ catch { }
+ }
+ return @{ Ok = $false; Rows = @(); RowCount = 0; Error = "Kusto query failed: $msg" }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1
new file mode 100644
index 000000000..2987dadf7
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1
@@ -0,0 +1,116 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# -- MG-Scope State --------------------------------------------------------
+# First cost module that gets 401/403 at MG scope sets this to $true.
+# All subsequent modules check it and skip to per-sub immediately.
+$script:MgCostScopeFailed = $false
+
+function Test-MgCostScope {
+ return (-not $script:MgCostScopeFailed)
+}
+
+function Set-MgCostScopeFailed {
+ $script:MgCostScopeFailed = $true
+ Write-Host " MG-scope cost access unavailable for this tenant - all subsequent modules will use per-subscription queries" -ForegroundColor Yellow
+}
+
+# -- Resolved Cost MG Scope ------------------------------------------------
+# Many orgs assign Cost Management Reader on a CHILD management group rather
+# than the tenant-root group (whose id == tenant GUID), so querying
+# managementGroups/ returns 401. Resolve-CostMgId probes the tenant
+# root first, then every accessible MG, and caches the first scope that returns
+# cost data. Falls back to per-subscription when none work.
+$script:CostMgId = $null
+
+function Reset-CostMgScope {
+ $script:CostMgId = $null
+ $script:MgCostScopeFailed = $false
+}
+
+function Resolve-CostMgId {
+ param(
+ [Parameter(Mandatory)]
+ [string]$TenantId
+ )
+
+ if ($script:MgCostScopeFailed) { return $null }
+ if ($script:CostMgId) { return $script:CostMgId }
+
+ # Candidates are the management groups the caller can actually see. Cost
+ # access usually lives on a child MG (not the tenant root), so probe the
+ # visible MGs first and fall back to the tenant root last. A throttled
+ # (429) probe must not abandon discovery - keep trying the rest.
+ $candidates = [System.Collections.Generic.List[string]]::new()
+
+ try {
+ $listResp = Invoke-AzRestMethodWithRetry -Path '/providers/Microsoft.Management/managementGroups?api-version=2020-05-01' -Method GET
+ if ($listResp -and $listResp.StatusCode -eq 200) {
+ $mgs = ($listResp.Content | ConvertFrom-Json).value
+ foreach ($mg in @($mgs)) {
+ $name = $mg.name
+ if ($name -and -not $candidates.Contains($name)) {
+ $candidates.Add($name)
+ }
+ if ($candidates.Count -ge 12) { break }
+ }
+ }
+ }
+ catch { }
+
+ # Tenant root as a last-resort candidate (covers orgs where the cost role
+ # is assigned at the root management group).
+ if (-not $candidates.Contains($TenantId)) {
+ $candidates.Add($TenantId)
+ }
+
+ $probeBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ # Use a low retry budget per probe so a throttled candidate fails fast and
+ # we move on to the next one. The real cost queries keep the full budget.
+ foreach ($mgId in $candidates) {
+ $path = "/providers/Microsoft.Management/managementGroups/$mgId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $probeBody -MaxRetries 2
+ if ($resp -and $resp.StatusCode -eq 200) {
+ $script:CostMgId = $mgId
+ if ($mgId -ne $TenantId) {
+ Write-Host " Cost scope resolved to management group '$mgId' (no cost role on tenant root)" -ForegroundColor Yellow
+ }
+ return $mgId
+ }
+ # 401/403 = no cost role here; 429 = throttled; anything else = unusable
+ # at this scope. In every case, keep probing the remaining candidates so
+ # a throttled tenant-root probe never blocks reaching the child MG.
+ }
+
+ Set-MgCostScopeFailed
+ return $null
+}
+
+# -- Shared Subscription-Scope Filter -------------------------------------
+# When the user picks a subset of subscriptions we still want the single fast
+# MG-scope cost query (one call covers the whole management group), but scoped
+# to only the selected subscriptions. The Cost Management Query API supports a
+# server-side dataset filter on the SubscriptionId dimension, so we build that
+# filter once and inject it into each cost query body. This avoids the slow
+# per-subscription fan-out (N calls per timeframe) that hammers the throttle.
+function Get-CostSubscriptionFilter {
+ param([object[]]$Subscriptions)
+ if (-not $Subscriptions -or $Subscriptions.Count -eq 0) { return $null }
+ $ids = @($Subscriptions | ForEach-Object { [string]$_.Id } | Where-Object { $_ })
+ if ($ids.Count -eq 0) { return $null }
+ return @{
+ dimensions = @{
+ name = 'SubscriptionId'
+ operator = 'In'
+ values = $ids
+ }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1
new file mode 100644
index 000000000..80d3ddc51
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1
@@ -0,0 +1,921 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# READ-FINOPSHUBDATA.PS1
+# FINOPS HUB STORAGE DATA READER
+###########################################################################
+# Purpose: Read cost data from a FinOps Hub storage account
+# Date: Created for FinOps Multitool TUI integration
+#
+# Description:
+# Reads FOCUS-schema cost data from a Hub storage account.
+# Prefers parquet from the ingestion container (normalized FOCUS);
+# falls back to CSV from msexports if ingestion is empty.
+# Parquet.Net + all transitive deps are auto-installed via nuget.exe.
+#
+# 1. Checks ingestion container for parquet (preferred)
+# 2. Falls back to msexports CSV if no parquet found
+# 3. Returns FOCUS-schema cost objects for Multitool consumption
+#
+# ── Parameters ──────────────────────────────────────────────
+# StorageAccountName Hub storage account name
+# ResourceGroupName Resource group containing the storage account
+# Months Number of months to read (default: 1)
+#
+# Prerequisites:
+# - Az.Storage module
+# - Storage Blob Data Reader RBAC on the Hub storage account
+###########################################################################
+
+# Helper: Convert JSON to hashtable (PS 5.1 compatible — no -AsHashtable)
+function ConvertTo-HashtableFromJson {
+ param([string]$Json)
+ $obj = $Json | ConvertFrom-Json -ErrorAction Stop
+ $ht = @{}
+ foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = $p.Value }
+ return $ht
+}
+
+function Get-FinOpsParquetCachePath {
+ # Per-user cache, not the world-writable shared temp dir. On a multi-user or
+ # shared host, %TEMP%/tmp lets another local principal pre-plant DLLs at a
+ # predictable path that we would then load into this process.
+ $base = [Environment]::GetFolderPath('LocalApplicationData')
+ if ([string]::IsNullOrWhiteSpace($base)) { $base = [System.IO.Path]::GetTempPath() }
+ return (Join-Path (Join-Path $base 'FinOpsMultitool') 'parquet')
+}
+
+# Every managed/native DLL the loader can pull in, in a stable order.
+function Get-ParquetPayloadFile {
+ param([Parameter(Mandatory)][string]$BasePath)
+ $roots = @((Join-Path $BasePath 'lib'), (Join-Path $BasePath 'runtimes'))
+ $files = foreach ($r in $roots) {
+ if (Test-Path -LiteralPath $r) {
+ Get-ChildItem -LiteralPath $r -Filter '*.dll' -Recurse -File -ErrorAction SilentlyContinue
+ }
+ }
+ return @($files | Sort-Object FullName)
+}
+
+function New-ParquetManifest {
+ param(
+ [Parameter(Mandatory)][string]$BasePath,
+ [Parameter(Mandatory)][string]$ManifestPath
+ )
+ $entries = @{}
+ foreach ($f in (Get-ParquetPayloadFile -BasePath $BasePath)) {
+ $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/')
+ $entries[$rel] = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash
+ }
+ [PSCustomObject]@{
+ version = '4.24.0'
+ created = (Get-Date).ToUniversalTime().ToString('o')
+ files = $entries
+ } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding UTF8
+}
+
+# Re-hashes every payload DLL against the manifest recorded at install time.
+# Runs on EVERY load, so a tampered cache cannot ride in behind a marker file.
+function Test-ParquetManifest {
+ param(
+ [Parameter(Mandatory)][string]$BasePath,
+ [Parameter(Mandatory)][string]$ManifestPath
+ )
+ if (-not (Test-Path -LiteralPath $ManifestPath)) { return $false }
+ try {
+ $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -ErrorAction Stop
+ }
+ catch { return $false }
+ if (-not $manifest.files) { return $false }
+
+ $recorded = @{}
+ foreach ($p in $manifest.files.PSObject.Properties) { $recorded[$p.Name] = [string]$p.Value }
+ if ($recorded.Count -eq 0) { return $false }
+
+ $onDisk = Get-ParquetPayloadFile -BasePath $BasePath
+ if ($onDisk.Count -ne $recorded.Count) { return $false }
+
+ foreach ($f in $onDisk) {
+ $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/')
+ if (-not $recorded.ContainsKey($rel)) { return $false }
+ if ((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash -ne $recorded[$rel]) { return $false }
+ }
+ return $true
+}
+
+# Validates nuget.org repository signatures on the packages we just fetched.
+# TLS alone only proves who we talked to, not that the payload is authentic.
+function Assert-NuGetPackageSignature {
+ param(
+ [Parameter(Mandatory)][string]$NuGetExe,
+ [Parameter(Mandatory)][string]$PackageDir
+ )
+ $nupkgs = @(Get-ChildItem -LiteralPath $PackageDir -Filter '*.nupkg' -Recurse -File -ErrorAction SilentlyContinue)
+ if ($nupkgs.Count -eq 0) {
+ throw "No .nupkg files were retained for signature verification. Refusing to load unverified assemblies."
+ }
+ foreach ($pkg in $nupkgs) {
+ $output = & $NuGetExe verify -Signatures $pkg.FullName 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "NuGet signature verification failed for $($pkg.Name): $($output -join ' ')"
+ }
+ }
+}
+
+function Install-ParquetReader {
+ [CmdletBinding()]
+ param()
+
+ # Check if Parquet is already loaded in this session
+ $loaded = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq 'Parquet'
+ }
+ if ($loaded) { return $true }
+
+ $parquetDir = Get-FinOpsParquetCachePath
+ $manifestFile = Join-Path $parquetDir 'parquet-manifest.json'
+
+ # Reuse a cached install only when every DLL still matches the hash recorded
+ # at install time.
+ if (Test-Path -LiteralPath $manifestFile) {
+ if (Test-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile) {
+ try {
+ Import-ParquetAssemblies -BasePath $parquetDir
+ return $true
+ }
+ catch {
+ Remove-Item $parquetDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+ else {
+ Write-Warning "Parquet cache failed integrity check - reinstalling."
+ Remove-Item $parquetDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ Write-Host " Installing Parquet reader (one-time setup)..." -ForegroundColor DarkGray
+
+ try {
+ New-Item -ItemType Directory -Path $parquetDir -Force | Out-Null
+
+ # Download nuget.exe if needed
+ $nugetExe = Join-Path $parquetDir 'nuget.exe'
+ if (-not (Test-Path $nugetExe)) {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+ Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe -UseBasicParsing
+
+ # Verify the downloaded nuget.exe is Authenticode-signed by Microsoft
+ # and the signature is Valid BEFORE executing it. The download URL is
+ # mutable ('/latest/'), so a tampered or unsigned binary (hijacked
+ # endpoint, MITM past TLS, cache poisoning) is deleted and refused
+ # rather than run. On non-Windows, Authenticode is not applicable and
+ # nuget.exe is not executed, so the check is skipped.
+ $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true }
+ if ($isWin) {
+ $sig = Get-AuthenticodeSignature -FilePath $nugetExe
+ $signerSubject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' }
+ $signerOk = $sig.SignerCertificate -and ($signerSubject -match 'O=Microsoft Corporation')
+ if ($sig.Status -ne 'Valid' -or -not $signerOk) {
+ Remove-Item $nugetExe -Force -ErrorAction SilentlyContinue
+ throw "Downloaded nuget.exe failed Authenticode validation (status: $($sig.Status); signer: $signerSubject). Refusing to execute it."
+ }
+ }
+ }
+
+ # Use nuget.exe to resolve ALL transitive dependencies
+ $pkgDir = Join-Path $parquetDir 'packages'
+ & $nugetExe install Parquet.Net -Version 4.24.0 -OutputDirectory $pkgDir -Framework net8.0 2>&1 | Out-Null
+
+ # Verify nuget.org signatures on the fetched packages before any of
+ # their assemblies are copied or loaded into this process.
+ Assert-NuGetPackageSignature -NuGetExe $nugetExe -PackageDir $pkgDir
+
+ # Copy managed DLLs to flat directory (prefer net8.0 > net6.0 > netstandard2.0)
+ $libDir = Join-Path $parquetDir 'lib'
+ New-Item -ItemType Directory -Path $libDir -Force | Out-Null
+
+ $fxPriority = @('net8.0', 'net6.0', 'netstandard2.1', 'netstandard2.0')
+ $packages = Get-ChildItem $pkgDir -Directory
+ foreach ($pkg in $packages) {
+ $libRoot = Join-Path $pkg.FullName 'lib'
+ if (-not (Test-Path $libRoot)) { continue }
+ $copied = $false
+ foreach ($fx in $fxPriority) {
+ $fxDir = Join-Path $libRoot $fx
+ if (Test-Path $fxDir) {
+ Get-ChildItem $fxDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName $libDir -Force
+ }
+ $copied = $true
+ break
+ }
+ }
+ # Copy native runtimes (IronCompress needs nironcompress.dll)
+ $nativeDir = Join-Path $pkg.FullName 'runtimes\win-x64\native'
+ if (Test-Path $nativeDir) {
+ $targetNative = Join-Path $parquetDir 'runtimes\win-x64\native'
+ New-Item -ItemType Directory -Path $targetNative -Force | Out-Null
+ Get-ChildItem $nativeDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName $targetNative -Force
+ }
+ }
+ }
+
+ # Record hashes of everything we just staged so later loads can detect
+ # tampering instead of trusting a bare marker file.
+ New-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile
+
+ Import-ParquetAssemblies -BasePath $parquetDir
+ Write-Host " Parquet reader installed." -ForegroundColor DarkGray
+ return $true
+ }
+ catch {
+ Write-Warning "Failed to install Parquet reader: $($_.Exception.Message)"
+ return $false
+ }
+}
+
+function Import-ParquetAssemblies {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$BasePath
+ )
+
+ $libDir = Join-Path $BasePath 'lib'
+
+ # Load order matters — dependencies before dependents
+ $loadOrder = @(
+ 'System.Buffers.dll'
+ 'System.Memory.dll'
+ 'System.Runtime.CompilerServices.Unsafe.dll'
+ 'System.Collections.Immutable.dll'
+ 'Microsoft.IO.RecyclableMemoryStream.dll'
+ 'ZstdSharp.dll'
+ 'Snappier.dll'
+ 'IronCompress.dll'
+ 'Apache.Arrow.dll'
+ 'Microsoft.ML.DataView.dll'
+ 'Microsoft.Data.Analysis.dll'
+ 'Parquet.dll'
+ )
+
+ foreach ($dll in $loadOrder) {
+ $path = Join-Path $libDir $dll
+ if (-not (Test-Path $path)) { continue }
+
+ $asmName = [IO.Path]::GetFileNameWithoutExtension($dll)
+ $already = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq $asmName
+ }
+ if ($already) { continue }
+
+ try {
+ Add-Type -Path $path -ErrorAction Stop
+ }
+ catch {
+ # Swallow if the runtime already provides this assembly
+ $recheck = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq $asmName
+ }
+ if (-not $recheck) { throw }
+ }
+ }
+}
+
+function Read-ParquetFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Path
+ )
+
+ try {
+ $table = [Parquet.ParquetReader]::ReadTableFromFileAsync($Path, $null).GetAwaiter().GetResult()
+ if (-not $table -or $table.Count -eq 0) { return @() }
+
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $colNames = @($table.Schema.GetDataFields() | ForEach-Object { $_.Name })
+
+ for ($i = 0; $i -lt $table.Count; $i++) {
+ $obj = [ordered]@{}
+ foreach ($colName in $colNames) {
+ $col = $table[$colName]
+ $obj[$colName] = if ($col -and $i -lt $col.Data.Length) { $col.Data[$i] } else { $null }
+ }
+ $results.Add([PSCustomObject]$obj)
+ }
+
+ return $results
+ }
+ catch {
+ Write-Warning "Failed to read parquet file $Path`: $($_.Exception.Message)"
+ return @()
+ }
+}
+
+function Read-FinOpsHubData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$StorageAccountName,
+
+ [Parameter(Mandatory)]
+ [string]$ResourceGroupName,
+
+ [Parameter()]
+ [int]$Months = 1
+ )
+
+ Write-Host " Connecting to Hub storage: $StorageAccountName" -ForegroundColor DarkGray
+
+ try {
+ $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop
+ }
+ catch {
+ Write-Host " Failed to connect to Hub storage: $($_.Exception.Message)" -ForegroundColor Yellow
+ return $null
+ }
+
+ $allData = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "FinOpsHub-$([guid]::NewGuid().ToString('N').Substring(0,8))"
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
+
+ try {
+ # -- Strategy 1: Parquet from ingestion (normalized FOCUS) ---------
+ $now = Get-Date
+ for ($m = 0; $m -lt $Months; $m++) {
+ $d = $now.AddMonths(-$m)
+ $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))"
+ try {
+ $blobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -ErrorAction Stop |
+ Where-Object { -not $_.IsDirectory -and $_.Name -like '*.parquet' })
+
+ if ($blobs.Count -gt 0) {
+ # Install parquet reader on first parquet file encountered
+ if ($allData.Count -eq 0) {
+ $hasParquet = Install-ParquetReader
+ if (-not $hasParquet) {
+ Write-Warning "Parquet reader failed — falling back to CSV exports"
+ break
+ }
+ }
+
+ Write-Host " Reading ingestion: $basePath ($($blobs.Count) file(s))" -ForegroundColor DarkGray
+ foreach ($blob in $blobs) {
+ $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N')).parquet"
+ try {
+ Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'ingestion' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null
+ $rows = Read-ParquetFile -Path $localFile
+ if ($rows -and @($rows).Count -gt 0) {
+ foreach ($row in $rows) { $allData.Add($row) }
+ Write-Host " Loaded $(@($rows).Count) rows from $(Split-Path $blob.Path -Leaf)" -ForegroundColor DarkGray
+ }
+ }
+ finally {
+ Remove-Item $localFile -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+ }
+ catch {
+ # Path doesn't exist yet — that's OK
+ }
+ }
+
+ # -- Strategy 2: CSV from msexports (raw FOCUS export) -------------
+ if ($allData.Count -eq 0) {
+ Write-Host " No parquet in ingestion — reading CSV from msexports..." -ForegroundColor DarkGray
+
+ $csvBlobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -Recurse -ErrorAction SilentlyContinue |
+ Where-Object { -not $_.IsDirectory -and $_.Path -like '*.csv' })
+
+ if ($csvBlobs.Count -gt 0) {
+ # Export layout is: ...////part_*.csv
+ # where billingPeriod = yyyyMMdd-yyyyMMdd and runTimestamp = 12 digits.
+ # Group by billing period, and within each period keep only the latest
+ # run. Walking periods newest-first and skipping empty ones means a
+ # just-started current month (header-only export) falls back to the
+ # latest populated period instead of returning nothing.
+ $periods = [ordered]@{}
+ foreach ($blob in $csvBlobs) {
+ $period = [regex]::Match($blob.Path, '(\d{8}-\d{8})').Value
+ $run = [regex]::Match($blob.Path, '[\\/](\d{12})[\\/]').Groups[1].Value
+ if (-not $period) { $period = Split-Path (Split-Path $blob.Path -Parent) -Parent }
+ if (-not $periods.Contains($period)) {
+ $periods[$period] = @{ Runs = [ordered]@{} }
+ }
+ if (-not $periods[$period].Runs.Contains($run)) {
+ $periods[$period].Runs[$run] = [System.Collections.Generic.List[object]]::new()
+ }
+ $periods[$period].Runs[$run].Add($blob)
+ }
+
+ # Newest billing period first.
+ $orderedPeriods = $periods.Keys | Sort-Object -Descending
+ $periodsLoaded = 0
+
+ foreach ($period in $orderedPeriods) {
+ if ($periodsLoaded -ge $Months) { break }
+
+ # Latest run within this period.
+ $latestRunKey = $periods[$period].Runs.Keys | Sort-Object -Descending | Select-Object -First 1
+ $runBlobs = $periods[$period].Runs[$latestRunKey]
+ Write-Host " Period $period — latest run has $($runBlobs.Count) CSV file(s)" -ForegroundColor DarkGray
+
+ $periodRows = 0
+ foreach ($blob in $runBlobs) {
+ $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N'))-$(Split-Path $blob.Path -Leaf)"
+ try {
+ Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'msexports' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null
+ $rows = Import-Csv -Path $localFile
+ if ($rows -and @($rows).Count -gt 0) {
+ foreach ($row in $rows) { $allData.Add($row) }
+ $periodRows += @($rows).Count
+ }
+ }
+ catch {
+ Write-Warning "Failed to read CSV: $($_.Exception.Message)"
+ }
+ finally {
+ Remove-Item $localFile -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ if ($periodRows -gt 0) {
+ Write-Host " Loaded $periodRows rows from period $period" -ForegroundColor DarkGray
+ $periodsLoaded++
+ }
+ else {
+ Write-Host " Period $period had no rows — skipping to next" -ForegroundColor DarkGray
+ }
+ }
+ }
+ else {
+ Write-Host " No cost data found in Hub storage" -ForegroundColor Yellow
+ }
+ }
+ }
+ finally {
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ if ($allData.Count -gt 0) {
+ $source = if ($allData[0].PSObject.Properties.Name -contains 'x_SkuTier') { 'CSV' } else { 'parquet' }
+ Write-Host " Total rows from Hub ($source): $($allData.Count)" -ForegroundColor Green
+ }
+
+ return $allData
+}
+
+function ConvertTo-CostDataFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Convert FOCUS-schema Hub data into the same hashtable format
+ # that Get-CostData returns: @{ subscriptionId = @{ Actual; Forecast; Currency } }
+ $costMap = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $subId = if ($props -contains 'SubAccountId' -and $row.SubAccountId) { $row.SubAccountId }
+ elseif ($props -contains 'SubscriptionId' -and $row.SubscriptionId) { $row.SubscriptionId }
+ elseif ($props -contains 'x_SubscriptionId' -and $row.x_SubscriptionId) { $row.x_SubscriptionId }
+ else { 'unknown' }
+
+ # FOCUS SubAccountId may be full resource path — extract just the GUID
+ if ($subId -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') {
+ $subId = $Matches[0]
+ }
+
+ # Display name from the FOCUS data so the UI can label by name, not GUID.
+ $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { [string]$row.SubAccountName }
+ elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { [string]$row.SubscriptionName }
+ else { '' }
+
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency }
+ elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode }
+ else { 'USD' }
+
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0.0; Forecast = 0.0; Currency = $currency; Name = $subName }
+ }
+ $costMap[$subId].Actual += $cost
+ }
+
+ return $costMap
+}
+
+function ConvertTo-ResourceCostsFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Aggregate by resource and return in the same format as Get-ResourceCosts
+ $resourceMap = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName }
+ elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName }
+ elseif ($props -contains 'SubAccountId') { $row.SubAccountId }
+ else { 'unknown' }
+
+ $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName }
+ elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup }
+ elseif ($props -contains 'ResourceGroupName' -and $row.ResourceGroupName) { $row.ResourceGroupName }
+ else { 'unknown' }
+
+ $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType }
+ elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType }
+ elseif ($props -contains 'ConsumedService' -and $row.ConsumedService) { $row.ConsumedService }
+ else { 'unknown' }
+
+ $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId }
+ elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId }
+ else { "$rg/$resType" }
+
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency }
+ elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode }
+ else { 'USD' }
+
+ $key = $resId
+ if (-not $resourceMap.ContainsKey($key)) {
+ $resourceMap[$key] = [PSCustomObject]@{
+ Subscription = $subName
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resId
+ Actual = 0.0
+ Forecast = 0.0
+ Currency = $currency
+ }
+ }
+ $resourceMap[$key].Actual += $cost
+ }
+
+ return @($resourceMap.Values | Sort-Object { $_.Actual } -Descending)
+}
+
+# Helper: pick the first present/non-empty property from a row.
+function Get-HubRowValue {
+ param(
+ [Parameter(Mandatory)][object]$Row,
+ [Parameter(Mandatory)][string[]]$Names,
+ [string[]]$Props
+ )
+ if (-not $Props) { $Props = $Row.PSObject.Properties.Name }
+ foreach ($n in $Names) {
+ if ($Props -contains $n -and $null -ne $Row.$n -and "$($Row.$n)".Trim() -ne '') {
+ return $Row.$n
+ }
+ }
+ return $null
+}
+
+# Helper: convert a billing unit string (e.g. "1K", "1M", "1,000",
+# "100 Tokens") into the number of tokens one unit of quantity represents.
+# Azure OpenAI token meters are billed per-1K tokens, so an unqualified
+# unit defaults to 1000 (flagged as approximate by the caller).
+function Get-TokenUnitMultiplier {
+ param([string]$Unit)
+ if (-not $Unit) { return @{ Multiplier = 1000.0; Known = $false } }
+ $u = $Unit.Trim()
+
+ # Pure number, e.g. "1000" or "1,000,000".
+ $numOnly = ($u -replace '[,\s]', '')
+ if ($numOnly -match '^\d+(\.\d+)?$') { return @{ Multiplier = [double]$numOnly; Known = $true } }
+
+ # Scaled, e.g. "1K", "10K", "1M".
+ $m = [regex]::Match($u, '(?i)([\d,\.]+)?\s*([KM])\b')
+ if ($m.Success) {
+ $n = if ($m.Groups[1].Value) { [double]($m.Groups[1].Value -replace ',', '') } else { 1.0 }
+ $scale = if ($m.Groups[2].Value -match '(?i)M') { 1e6 } else { 1e3 }
+ return @{ Multiplier = ($n * $scale); Known = $true }
+ }
+
+ # Plain "tokens" / "count" / "units" — quantity is already raw tokens.
+ if ($u -match '(?i)\b(token|tokens|count|units|unit)\b') { return @{ Multiplier = 1.0; Known = $true } }
+
+ # Unknown unit — assume the AOAI per-1K convention but flag it.
+ return @{ Multiplier = 1000.0; Known = $false }
+}
+
+# Helper: strip token-type and qualifier words from an Azure OpenAI meter
+# name to derive a model/deployment grouping key.
+function Get-AIModelKeyFromMeter {
+ param([string]$Meter)
+ if (-not $Meter) { return 'unknown' }
+ $k = $Meter -replace '(?i)\b(inp|input|prompt|outp|output|generated|completion|cached|cache|regional|global|glbl|reg|data|stored|tokens|token)\b', ''
+ $k = ($k -replace '\s+', ' ').Trim(' -')
+ if ([string]::IsNullOrWhiteSpace($k)) { return ([string]$Meter).Trim() }
+ return $k
+}
+
+function ConvertTo-AIHubAggregates {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [AllowEmptyCollection()]
+ [object[]]$HubData
+ )
+
+ # Aggregate Azure OpenAI / Cognitive Services spend and billed token
+ # volume straight from FOCUS export rows. Only cognitiveservices/accounts
+ # rows are priced (matching the live Cost Management filter). Request
+ # counts are not billed line items, so cost-per-request is unavailable
+ # on this path.
+ $modelTokens = @{} # modelKey -> @{ Prompt; Generated; Total }
+ $acctTokens = @{} # resourceId(lower) -> @{ Name; Tokens; Requests }
+ $costByAcct = @{} # resourceId(lower) -> cost
+ $totalPrompt = 0.0
+ $totalGen = 0.0
+ $totalTokens = 0.0
+ $aiCost = 0.0
+ $currency = 'USD'
+ $tokenRows = 0
+ $approximate = $false
+
+ if (-not $HubData -or @($HubData).Count -eq 0) {
+ return [PSCustomObject]@{
+ RowCount = 0; TotalPrompt = 0; TotalGen = 0; TotalTokens = 0
+ AICost = 0; Currency = $currency; ModelTokens = $modelTokens
+ AcctTokens = $acctTokens; CostByAcct = $costByAcct
+ HasTokens = $false; HasCost = $false; Approximate = $false
+ }
+ }
+
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $resType = Get-HubRowValue -Row $row -Props $props -Names @('ResourceType', 'x_ResourceType', 'ConsumedService')
+ if (-not $resType -or "$resType".ToLowerInvariant() -notmatch 'cognitiveservices') { continue }
+
+ $rid = Get-HubRowValue -Row $row -Props $props -Names @('ResourceId', 'x_ResourceId')
+ if (-not $rid) { continue }
+ $ridKey = "$rid".ToLowerInvariant()
+
+ $name = Get-HubRowValue -Row $row -Props $props -Names @('ResourceName')
+ if (-not $name) { $name = Split-Path "$rid" -Leaf }
+
+ $cost = Get-HubRowValue -Row $row -Props $props -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost')
+ $cost = if ($null -ne $cost) { [double]$cost } else { 0.0 }
+
+ $cur = Get-HubRowValue -Row $row -Props $props -Names @('BillingCurrency', 'BillingCurrencyCode')
+ if ($cur) { $currency = "$cur" }
+
+ if (-not $acctTokens.ContainsKey($ridKey)) {
+ $acctTokens[$ridKey] = @{ Name = "$name"; Tokens = 0.0; Requests = 0.0 }
+ }
+ if (-not $costByAcct.ContainsKey($ridKey)) { $costByAcct[$ridKey] = 0.0 }
+ $costByAcct[$ridKey] += $cost
+ $aiCost += $cost
+
+ # Token attribution from the meter name + billed quantity.
+ $meter = Get-HubRowValue -Row $row -Props $props -Names @('x_SkuMeterName', 'MeterName', 'SkuMeterName', 'x_SkuDescription')
+ if (-not $meter -or "$meter" -notmatch '(?i)token') { continue }
+
+ $qty = Get-HubRowValue -Row $row -Props $props -Names @('ConsumedQuantity', 'x_ConsumedQuantity', 'Quantity', 'UsageQuantity')
+ $qty = if ($null -ne $qty) { [double]$qty } else { 0.0 }
+ if ($qty -le 0) { continue }
+
+ $unit = Get-HubRowValue -Row $row -Props $props -Names @('ConsumedUnit', 'x_PricingUnitDescription', 'UnitOfMeasure', 'PricingUnit')
+ $mult = Get-TokenUnitMultiplier -Unit "$unit"
+ if (-not $mult.Known) { $approximate = $true }
+ $tokens = $qty * $mult.Multiplier
+
+ $modelKey = Get-AIModelKeyFromMeter -Meter "$meter"
+ if (-not $modelTokens.ContainsKey($modelKey)) {
+ $modelTokens[$modelKey] = @{ Prompt = 0.0; Generated = 0.0; Total = 0.0 }
+ }
+
+ if ("$meter" -match '(?i)\b(inp|input|prompt|cached|cache)\b') {
+ $modelTokens[$modelKey].Prompt += $tokens
+ $totalPrompt += $tokens
+ $acctTokens[$ridKey].Tokens += $tokens
+ }
+ elseif ("$meter" -match '(?i)\b(outp|output|generated|completion)\b') {
+ $modelTokens[$modelKey].Generated += $tokens
+ $totalGen += $tokens
+ $acctTokens[$ridKey].Tokens += $tokens
+ }
+ else {
+ $acctTokens[$ridKey].Tokens += $tokens
+ }
+ $modelTokens[$modelKey].Total += $tokens
+ $totalTokens += $tokens
+ $tokenRows++
+ }
+
+ return [PSCustomObject]@{
+ RowCount = $tokenRows
+ TotalPrompt = $totalPrompt
+ TotalGen = $totalGen
+ TotalTokens = $totalTokens
+ AICost = $aiCost
+ Currency = $currency
+ ModelTokens = $modelTokens
+ AcctTokens = $acctTokens
+ CostByAcct = $costByAcct
+ HasTokens = ($totalTokens -gt 0)
+ HasCost = ($aiCost -gt 0)
+ Approximate = $approximate
+ }
+}
+
+function ConvertTo-TagInventoryFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Extract tag inventory from FOCUS cost data Tags JSON column
+ # Returns same structure as Get-TagInventory
+ $tagNames = @{}
+ $totalResources = 0
+ $taggedCount = 0
+ $untaggedResources = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $seenResources = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId }
+ elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId }
+ else { $null }
+
+ # Deduplicate by resource ID (cost rows repeat per line item)
+ if (-not $resId -or $seenResources.ContainsKey($resId)) { continue }
+ $seenResources[$resId] = $true
+ $totalResources++
+
+ $resName = if ($props -contains 'ResourceName' -and $row.ResourceName) { $row.ResourceName } else { Split-Path $resId -Leaf }
+ $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType }
+ elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType }
+ else { 'unknown' }
+ $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName }
+ elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup }
+ else { 'unknown' }
+ $sub = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName }
+ elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName }
+ else { 'unknown' }
+
+ # Parse Tags JSON
+ $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null }
+ $tagDict = $null
+ if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') {
+ try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { }
+ }
+
+ if ($tagDict -and $tagDict.Count -gt 0) {
+ $taggedCount++
+ foreach ($kv in $tagDict.GetEnumerator()) {
+ $tName = $kv.Key
+ $tVal = if ($kv.Value) { "$($kv.Value)" } else { '(empty)' }
+
+ if (-not $tagNames.ContainsKey($tName)) {
+ $tagNames[$tName] = @{
+ Values = @{}
+ TotalResources = 0
+ }
+ }
+ $tagNames[$tName].TotalResources++
+
+ if (-not $tagNames[$tName].Values.ContainsKey($tVal)) {
+ $tagNames[$tName].Values[$tVal] = @{ ResourceCount = 0; ResourceTypes = @{} }
+ }
+ $tagNames[$tName].Values[$tVal].ResourceCount++
+ $tagNames[$tName].Values[$tVal].ResourceTypes[$resType] = $true
+ }
+ }
+ else {
+ if ($untaggedResources.Count -lt 500) {
+ $untaggedResources.Add([PSCustomObject]@{
+ ResourceName = $resName
+ ResourceType = $resType
+ ResourceGroup = $rg
+ Subscription = $sub
+ Location = ''
+ })
+ }
+ }
+ }
+
+ # Convert Values hashes to arrays matching Get-TagInventory format
+ $tagNamesOut = @{}
+ foreach ($kv in $tagNames.GetEnumerator()) {
+ $valArray = @()
+ foreach ($v in $kv.Value.Values.GetEnumerator()) {
+ $valArray += [PSCustomObject]@{
+ TagValue = $v.Key
+ ResourceCount = $v.Value.ResourceCount
+ ResourceTypes = @($v.Value.ResourceTypes.Keys)
+ }
+ }
+ $tagNamesOut[$kv.Key] = @{
+ Values = ($valArray | Sort-Object ResourceCount -Descending)
+ TotalResources = $kv.Value.TotalResources
+ }
+ }
+
+ $untaggedCount = $totalResources - $taggedCount
+ $coverage = if ($totalResources -gt 0) { [math]::Round(($taggedCount / $totalResources) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ TagNames = $tagNamesOut
+ TagCount = $tagNamesOut.Count
+ TotalResources = $totalResources
+ TaggedCount = $taggedCount
+ UntaggedCount = $untaggedCount
+ TagCoverage = $coverage
+ UntaggedResources = @($untaggedResources)
+ Source = 'Hub'
+ }
+}
+
+function ConvertTo-CostByTagFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData,
+
+ [Parameter()]
+ [hashtable]$ExistingTags
+ )
+
+ # Aggregate cost by tag key/value from FOCUS cost data
+ # Returns same structure as Get-CostByTag
+ $props = $HubData[0].PSObject.Properties.Name
+ $costByTag = @{}
+ $currency = 'USD'
+
+ # Determine which tags to report on
+ $targetTags = if ($ExistingTags -and $ExistingTags.Count -gt 0) {
+ @($ExistingTags.Keys)
+ }
+ else { @() }
+
+ foreach ($row in $HubData) {
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $currency = $row.BillingCurrency }
+
+ $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null }
+ $tagDict = $null
+ if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') {
+ try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { }
+ }
+
+ if ($targetTags.Count -eq 0 -and $tagDict -and $tagDict.Count -gt 0) {
+ $targetTags = @($tagDict.Keys)
+ }
+
+ foreach ($tagKey in $targetTags) {
+ if (-not $costByTag.ContainsKey($tagKey)) { $costByTag[$tagKey] = @{} }
+ $tagVal = if ($tagDict -and $tagDict.ContainsKey($tagKey)) { "$($tagDict[$tagKey])" } else { '(untagged)' }
+ if (-not $tagVal -or $tagVal -eq '') { $tagVal = '(empty)' }
+
+ if (-not $costByTag[$tagKey].ContainsKey($tagVal)) { $costByTag[$tagKey][$tagVal] = 0.0 }
+ $costByTag[$tagKey][$tagVal] += $cost
+ }
+ }
+
+ # Convert to output format matching Get-CostByTag
+ $costByTagOut = @{}
+ foreach ($kv in $costByTag.GetEnumerator()) {
+ $costByTagOut[$kv.Key] = @($kv.Value.GetEnumerator() | ForEach-Object {
+ [PSCustomObject]@{
+ TagValue = $_.Key
+ Cost = [math]::Round($_.Value, 2)
+ Currency = $currency
+ }
+ } | Sort-Object Cost -Descending)
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = @($costByTagOut.Keys)
+ CostByTag = $costByTagOut
+ NoTagsFound = ($costByTagOut.Count -eq 0)
+ UsedTimeframe = 'Hub export period'
+ Source = 'Hub'
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1
new file mode 100644
index 000000000..68b6d4855
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1
@@ -0,0 +1,526 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# RESOLVE-COSTDATASOURCE.PS1
+# COST DATA SOURCE RESOLVER (EXPORT-FIRST ROUTING)
+###########################################################################
+# Purpose: Decide whether cost scans should read FinOps Hub / Cost
+# Management export data (fast) or the live Cost Management API.
+# Date: Created for FinOps Multitool export-first routing
+#
+# Description:
+# Non-interactive detector used by the TUI and the agent skills.
+# It inspects the requested scope and returns a structured decision so
+# the agent can take the fast path when an export is readable, or set
+# expectations (and ask) before falling back to the slow API path.
+#
+# 1. Detects a FinOps Hub storage account via Resource Graph
+# 2. Verifies the hub storage is actually readable, with a blocker reason
+# (RBAC / public access disabled / firewall deny) when it is not
+# 3. Determines which subscriptions the export covers vs. those requested
+# 4. Reports export freshness (latest data date)
+# 5. Estimates how long the live API path would take (resource-count based)
+# 6. Emits a recommendation:
+# UseHub | UseHubPartial | FixAccessThenHub (FinOps Hub fast path)
+# UseExport | UseExportPartial (generic CSV export)
+# UseApi (live Cost Management API)
+#
+# ── Parameters ──────────────────────────────────────────────
+# RequestedSubscriptionIds Subscription IDs the caller intends to scan
+# TenantId Tenant ID (for messaging only)
+#
+# Prerequisites:
+# - Az.ResourceGraph, Az.Storage, Az.Accounts modules
+# - Search-AzGraphSafe + RunspacePool helpers loaded
+# - Read access to the Hub storage account for the fast path (optional)
+###########################################################################
+
+function Resolve-CostDataSource {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string[]]$RequestedSubscriptionIds,
+
+ [string]$TenantId
+ )
+
+ $requested = @($RequestedSubscriptionIds | Where-Object { $_ } | Select-Object -Unique)
+ $subCount = $requested.Count
+
+ # -- Default result (no hub found → API is the only option) -----------
+ $result = [ordered]@{
+ HubFound = $false
+ Hub = $null
+ Readable = $false
+ ReadBlocker = 'None'
+ ReadBlockerDetail = $null
+ RemediationHint = $null
+ CoverageSubs = @()
+ RequestedSubs = $requested
+ CoveragePct = 0
+ Freshness = $null
+ EstimatedApiSeconds = 0
+ Recommendation = 'UseApi'
+ ExportFound = $false
+ Exports = @()
+ # Online scalable path: the hub's Azure Data Explorer (Kusto) cluster,
+ # discovered via Resource Graph. When present, cost scans push
+ # aggregation into the engine instead of reading rows.
+ KustoClusterUri = $null
+ KustoDatabase = $null
+ HubVersion = $null
+ Message = $null
+ }
+
+ # -- Step 1: estimate the API path duration (resource-count based) -----
+ # One cheap Graph query: total resources in scope. Cost queries scale
+ # roughly with subscription count plus a small per-resource component.
+ $resourceCount = 0
+ try {
+ $countQuery = 'Resources | summarize c = count()'
+ $countRes = Search-AzGraphSafe -Query $countQuery -Subscription $requested -First 1
+ if ($countRes -and $countRes.Data -and @($countRes.Data).Count -gt 0) {
+ $resourceCount = [int]($countRes.Data[0].c)
+ }
+ }
+ catch {
+ # Non-fatal — fall back to a subscription-only estimate
+ }
+
+ # ~10s base per subscription (MG attempt + per-sub fallback + throttle
+ # backoff) plus ~1s per 500 resources for the resource-cost breakdown.
+ $result.EstimatedApiSeconds = [int]([math]::Ceiling(($subCount * 10) + ($resourceCount / 500.0)))
+
+ # -- Step 2: detect a FinOps Hub via Resource Graph -------------------
+ $hub = $null
+ try {
+ $hubQuery = @"
+Resources
+| where type == 'microsoft.storage/storageaccounts'
+| where tags['cm-resource-parent'] contains 'Microsoft.Cloud/hubs'
+| project name, resourceGroup, subscriptionId, location
+"@
+ $hubRes = Search-AzGraphSafe -Query $hubQuery -Subscription $requested -First 5
+ if ($hubRes -and $hubRes.Data -and @($hubRes.Data).Count -gt 0) {
+ $hub = @($hubRes.Data)[0]
+ }
+ }
+ catch {
+ # Detection failed — treat as no hub
+ }
+
+ if (-not $hub) {
+ # No FinOps Hub in scope — fall back to detecting any Cost Management
+ # export the caller can read (the generic CSV export fast path).
+ $exp = Resolve-GenericExportSource -RequestedSubscriptionIds $requested
+ if ($exp.ExportFound) {
+ $result.ExportFound = $true
+ $result.Exports = $exp.Exports
+ $result.CoverageSubs = $exp.CoverageSubs
+ $result.Freshness = $exp.Freshness
+ $result.CoveragePct = $exp.CoveragePct
+ $result.Recommendation = $exp.Recommendation
+ $result.Message = $exp.Message
+ return [pscustomobject]$result
+ }
+
+ $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1)
+ $extra = if ($exp.Message) { " $($exp.Message)" } else { '' }
+ $result.Message = "No FinOps Hub found in scope. Cost scans will use the Cost Management API (~$apiMin min for $subCount subscription(s)).$extra"
+ return [pscustomobject]$result
+ }
+
+ $result.HubFound = $true
+ $result.Hub = [ordered]@{
+ Name = $hub.name
+ ResourceGroup = $hub.resourceGroup
+ SubscriptionId = $hub.subscriptionId
+ Location = $hub.location
+ }
+
+ # -- Discover the hub's Azure Data Explorer (Kusto) cluster -----------
+ # The scalable ONLINE path. The toolkit deploys the cluster alongside the
+ # hub storage (same resource group) and tags it ftk-tool == 'FinOps hubs'.
+ # This is the same discovery the toolkit's own ftk-hubs-connect flow uses.
+ # Attached here so it flows through every return path below and the
+ # detect tool can advertise it without a second Resource Graph call.
+ $cluster = Get-HubKustoCluster -RequestedSubscriptionIds $requested -HubResourceGroup $hub.resourceGroup
+ if ($cluster -and $cluster.ClusterUri) {
+ $result.KustoClusterUri = $cluster.ClusterUri
+ $result.KustoDatabase = 'Hub'
+ $result.HubVersion = $cluster.HubVersion
+ }
+
+ # -- Step 3: verify the hub storage is readable -----------------------
+ $access = Test-HubStorageAccess -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup
+ $result.Readable = $access.Readable
+ $result.ReadBlocker = $access.Blocker
+ $result.ReadBlockerDetail = $access.Detail
+ $result.RemediationHint = $access.Remediation
+
+ if (-not $access.Readable) {
+ $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1)
+ if ($result.KustoClusterUri) {
+ # Storage isn't readable, but the hub's Kusto cluster is the
+ # scalable path anyway - prefer it. Storage access is not required.
+ $result.Recommendation = 'UseHub'
+ $result.Message = "FinOps Hub '$($hub.name)' storage is not directly readable ($($access.Detail)), but its Kusto cluster ($($result.KustoClusterUri)) was found. Cost scans query the cluster directly (aggregation pushed into the engine). If you also need storage reads, $($access.Remediation)"
+ return [pscustomobject]$result
+ }
+ $result.Recommendation = 'FixAccessThenHub'
+ $result.Message = "FinOps Hub '$($hub.name)' found but not readable: $($access.Detail) $($access.Remediation) Otherwise fall back to the Cost Management API (~$apiMin min)."
+ return [pscustomobject]$result
+ }
+
+ # -- Step 4: coverage (which subscriptions the export contains) -------
+ $coverage = Get-HubCoverage -Context $access.Context
+ $result.CoverageSubs = $coverage.Subs
+ $result.Freshness = $coverage.Freshness
+
+ $coveredRequested = @($requested | Where-Object { $coverage.Subs -contains $_ })
+ if ($coverage.Subs.Count -eq 0) {
+ # Coverage could not be determined — assume it covers the scope but
+ # flag it so the agent can mention the uncertainty.
+ $result.CoveragePct = 100
+ $result.Recommendation = 'UseHub'
+ $kustoNote0 = if ($result.KustoClusterUri) {
+ " A FinOps Hub Kusto cluster was found ($($result.KustoClusterUri)); cost scans query it directly and push aggregation into the engine (scales to large datasets)."
+ }
+ else {
+ ' Cost scans use the FinOps Hub storage reader (small-dataset convenience path). For large hubs, use a Kusto database (Azure Data Explorer / Fabric, or set FINOPS_HUB_KUSTO_URI for a local ftklocal emulator).'
+ }
+ $result.Message = "FinOps Hub '$($hub.name)' is readable. Coverage could not be confirmed from export metadata; proceeding with hub data (verify the 'as of' date in results).$kustoNote0"
+ return [pscustomobject]$result
+ }
+
+ $result.CoveragePct = [int][math]::Round((($coveredRequested.Count / [double]$subCount) * 100))
+
+ $asOf = if ($coverage.Freshness) { " as of $($coverage.Freshness)" } else { '' }
+ $kustoNote = if ($result.KustoClusterUri) {
+ " A FinOps Hub Kusto cluster was found ($($result.KustoClusterUri)); cost scans query it directly and push aggregation into the engine (scales to large datasets)."
+ }
+ else {
+ ' Cost scans use the FinOps Hub storage reader (rows aggregated in PowerShell) - the small-dataset convenience path. For large hubs, use a Kusto database: an Azure Data Explorer / Fabric cluster, or set FINOPS_HUB_KUSTO_URI to a local ftklocal emulator.'
+ }
+ if ($coveredRequested.Count -eq $subCount) {
+ $result.Recommendation = 'UseHub'
+ $result.Message = "FinOps Hub '$($hub.name)' covers all $subCount requested subscription(s)$asOf.$kustoNote"
+ }
+ else {
+ $result.Recommendation = 'UseHubPartial'
+ $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1)
+ $result.Message = "FinOps Hub '$($hub.name)' covers $($coveredRequested.Count) of $subCount subscription(s)$asOf. Choose: hub-only (partial), full API (~$apiMin min), or hybrid (hub where covered, API for the rest).$kustoNote"
+ }
+
+ return [pscustomobject]$result
+}
+
+function Get-HubKustoCluster {
+ # Discover the FinOps Hub's Azure Data Explorer (Kusto) cluster via Resource
+ # Graph. Mirrors the toolkit's own ftk-hubs-connect query: the cluster is
+ # tagged ftk-tool == 'FinOps hubs' and (for toolkit deployments) lives in the
+ # hub's resource group. Returns @{ ClusterUri; HubVersion; ResourceGroup }
+ # or $null. Read-only; failures degrade to no cluster (storage path is used).
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string[]]$RequestedSubscriptionIds,
+ [string]$HubResourceGroup
+ )
+ try {
+ $query = @"
+resources
+| where type =~ 'microsoft.kusto/clusters'
+| where tags['ftk-tool'] == 'FinOps hubs'
+| extend hubVersion = tostring(tags['ftk-version'])
+| project clusterUri = tostring(properties['uri']), hubVersion, resourceGroup, subscriptionId
+"@
+ $res = Search-AzGraphSafe -Query $query -Subscription $RequestedSubscriptionIds -First 10
+ if (-not $res -or -not $res.Data -or @($res.Data).Count -eq 0) { return $null }
+
+ $rows = @($res.Data | Where-Object { $_.clusterUri })
+ if ($rows.Count -eq 0) { return $null }
+
+ # Prefer the cluster in the same resource group as the hub storage
+ # (toolkit co-locates them); otherwise take the first FinOps hub cluster.
+ $match = $null
+ if ($HubResourceGroup) {
+ $match = $rows | Where-Object { $_.resourceGroup -eq $HubResourceGroup } | Select-Object -First 1
+ }
+ if (-not $match) { $match = $rows[0] }
+
+ return @{
+ ClusterUri = [string]$match.clusterUri
+ HubVersion = if ($match.hubVersion) { [string]$match.hubVersion } else { $null }
+ ResourceGroup = [string]$match.resourceGroup
+ }
+ }
+ catch {
+ return $null
+ }
+}
+
+function Test-HubStorageAccess {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$StorageAccountName,
+ [Parameter(Mandatory)][string]$ResourceGroupName
+ )
+
+ $out = @{
+ Readable = $false
+ Blocker = 'Unknown'
+ Detail = $null
+ Remediation = $null
+ Context = $null
+ }
+
+ # Ensure the storage module is available before calling its cmdlets so
+ # a module-load failure is not mistaken for an access problem.
+ foreach ($m in @('Az.Accounts', 'Az.Storage')) {
+ if (-not (Get-Module $m)) { Import-Module $m -ErrorAction SilentlyContinue }
+ }
+ if (-not (Get-Module 'Az.Storage')) {
+ $out.Blocker = 'DependencyMissing'
+ $out.Detail = 'The Az.Storage module is not installed or could not be loaded.'
+ $out.Remediation = "Install it with: Install-Module Az.Storage -Scope CurrentUser"
+ return $out
+ }
+
+ # Read network configuration first so blockers can be named precisely.
+ $publicAccess = $null
+ $defaultAction = $null
+ try {
+ $acct = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName -ErrorAction Stop
+ $publicAccess = $acct.PublicNetworkAccess
+ $defaultAction = $acct.NetworkRuleSet.DefaultAction
+ }
+ catch {
+ $msg = $_.Exception.Message
+ if ($msg -match 'could not be loaded|not loaded|Install-Module') {
+ $out.Blocker = 'DependencyMissing'
+ $out.Detail = "A required module failed to load: $msg"
+ $out.Remediation = 'Ensure Az.Storage and Az.Accounts are installed and importable.'
+ }
+ else {
+ # ARM read failed — likely no reader role on the account.
+ $out.Blocker = 'NoRbac'
+ $out.Detail = "Cannot read the storage account ('$msg')."
+ $out.Remediation = 'Grant at least Reader on the hub storage account.'
+ }
+ return $out
+ }
+
+ # Attempt a lightweight data-plane read to confirm true readability.
+ try {
+ $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop
+ $null = Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -MaxCount 1 -ErrorAction Stop
+ $out.Readable = $true
+ $out.Blocker = 'None'
+ $out.Context = $ctx
+ return $out
+ }
+ catch {
+ $msg = $_.Exception.Message
+ }
+
+ # Classify the failure into an actionable blocker.
+ if ("$publicAccess" -eq 'Disabled') {
+ $out.Blocker = 'PublicAccessDisabled'
+ $out.Detail = 'Public network access is disabled on the hub storage account.'
+ $out.Remediation = 'Re-enable public network access (or run from an allowed private endpoint), then retry.'
+ }
+ elseif ($msg -match '403|AuthorizationPermissionMismatch|does not have permission|not authorized') {
+ $out.Blocker = 'NoRbac'
+ $out.Detail = 'You lack a data role on the hub storage account.'
+ $out.Remediation = "Grant 'Storage Blob Data Reader' on the hub storage account, then retry."
+ }
+ elseif ("$defaultAction" -eq 'Deny' -or $msg -match '403.*firewall|network rule|not allowed to access') {
+ $out.Blocker = 'NetworkDenied'
+ $out.Detail = 'The hub storage firewall denied access from this network.'
+ $out.Remediation = 'Add your IP to the storage firewall, or run from an allowed network/private endpoint.'
+ }
+ else {
+ $out.Blocker = 'Unknown'
+ $out.Detail = "Hub storage read failed: $msg"
+ $out.Remediation = 'Verify access to the hub storage account, then retry.'
+ }
+
+ return $out
+}
+
+function Get-HubCoverage {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]$Context
+ )
+
+ $out = @{
+ Subs = @()
+ Freshness = $null
+ }
+
+ # The Cost Management manifest written per export run records the export
+ # scope and the data date range. Reading the newest manifest gives both
+ # coverage and freshness without downloading the cost rows themselves.
+ try {
+ $manifests = @(Get-AzDataLakeGen2ChildItem -Context $Context -FileSystem 'msexports' -Recurse -ErrorAction Stop |
+ Where-Object { -not $_.IsDirectory -and $_.Path -like '*manifest.json' })
+
+ if ($manifests.Count -eq 0) { return $out }
+
+ # Newest run first (run folders are timestamped in the path).
+ $manifests = $manifests | Sort-Object Path -Descending
+
+ $subs = [System.Collections.Generic.HashSet[string]]::new()
+ $latestDate = $null
+
+ # Inspect up to a handful of recent manifests to gather coverage.
+ foreach ($m in ($manifests | Select-Object -First 10)) {
+ $tempFile = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-manifest-$([guid]::NewGuid().ToString('N')).json"
+ try {
+ Get-AzDataLakeGen2ItemContent -Context $Context -FileSystem 'msexports' -Path $m.Path -Destination $tempFile -Force -ErrorAction Stop | Out-Null
+ $json = Get-Content -Path $tempFile -Raw | ConvertFrom-Json -ErrorAction Stop
+
+ $scope = $json.exportConfig.resourceId
+ if (-not $scope) { $scope = $json.runInfo.scope }
+ if ($scope -match '/subscriptions/([0-9a-f-]{36})') {
+ [void]$subs.Add($Matches[1])
+ }
+
+ $end = $json.dateRange.end
+ if (-not $end) { $end = $json.runInfo.endDate }
+ if ($end) {
+ try {
+ $d = [datetime]$end
+ if (-not $latestDate -or $d -gt $latestDate) { $latestDate = $d }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ finally {
+ Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ $out.Subs = @($subs)
+ if ($latestDate) { $out.Freshness = $latestDate.ToString('yyyy-MM-dd') }
+ }
+ catch {
+ # Coverage stays empty (unknown) on any failure.
+ }
+
+ return $out
+}
+
+# -- Generic Cost Management export fallback ------------------------------
+# When no FinOps Hub is present, look for any Cost Management export the
+# caller can read (classic or FOCUS, CSV). Picks the newest-run CSV export
+# per subscription (deduping overlapping exports so cost is not double
+# counted), and reports coverage + freshness so the caller can take the
+# export fast path the same way it does for a hub. CSV only — Parquet
+# exports are detected and reported but not read in PowerShell.
+function Resolve-GenericExportSource {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string[]]$RequestedSubscriptionIds)
+
+ $out = @{
+ ExportFound = $false
+ Exports = @()
+ CoverageSubs = @()
+ CoveragePct = 0
+ Freshness = $null
+ Recommendation = 'UseApi'
+ Message = $null
+ }
+
+ # Export module not loaded — nothing to do.
+ if (-not (Get-Command Find-CostExport -ErrorAction SilentlyContinue)) { return $out }
+
+ $requested = @($RequestedSubscriptionIds | Where-Object { $_ } | Select-Object -Unique)
+ if ($requested.Count -eq 0) { return $out }
+ $subCount = $requested.Count
+
+ # Find-CostExport needs subscription objects (Id + Name). The resolver
+ # only has IDs; names are not needed for detection (the dispatch
+ # supplies the real sub objects to the converters).
+ $subObjs = $requested | ForEach-Object { [pscustomobject]@{ Id = $_; Name = $_ } }
+
+ $exports = @()
+ try { $exports = @(Find-CostExport -Subscriptions $subObjs) } catch { $exports = @() }
+
+ # Storage-first discovery: some exports can't be found via Cost Management
+ # at all from this tenant - e.g. a hub export defined at a customer's
+ # management group (in the customer's tenant) and delivered cross-tenant
+ # via Azure Lighthouse, which only delegates subscription scope. The
+ # definition is invisible, but the blobs land in a storage account we can
+ # read. Reconstruct those from the blob layout and merge (deduped).
+ #
+ # Always runs (not just when control plane is empty): the common
+ # cross-tenant case is MIXED - control plane surfaces some subs' exports
+ # while the MG-scoped hub export stays invisible. The tax is modest
+ # (~1 ARM call/sub + 1 container-list/storage-acct; blob-listing only on
+ # accounts that actually have an export-named container).
+ if (Get-Command Find-CostExportFromStorage -ErrorAction SilentlyContinue) {
+ try {
+ $knownKeys = @{}
+ foreach ($e in $exports) {
+ if ($e.StorageResourceId -and $e.Container -and $e.Name) {
+ $knownKeys[("$($e.StorageResourceId)|$($e.Container)|$($e.Name)").ToLowerInvariant()] = $true
+ }
+ }
+ $storageExports = @(Find-CostExportFromStorage -Subscriptions $subObjs -KnownKeys $knownKeys)
+ if ($storageExports.Count -gt 0) { $exports = @($exports) + $storageExports }
+ }
+ catch { }
+ }
+
+ if ($exports.Count -eq 0) { return $out }
+
+ $csv = @($exports | Where-Object { $_.Format -match 'csv' })
+ if ($csv.Count -eq 0) {
+ $out.Message = 'Found Cost Management export(s), but they write Parquet, which is not read in PowerShell. Recreate the export with CSV format to enable the export fast path.'
+ return $out
+ }
+
+ # Dedupe by subscription: keep the newest-run CSV export per SubId so two
+ # exports covering the same subscription do not double-count.
+ $bestBySub = @{}
+ foreach ($e in $csv) {
+ $sid = "$($e.SubId)"
+ if ([string]::IsNullOrWhiteSpace($sid)) { continue }
+ $existing = $bestBySub[$sid]
+ if (-not $existing) { $bestBySub[$sid] = $e; continue }
+ $a = if ($e.LastRunDate) { [datetime]$e.LastRunDate } else { [datetime]::MinValue }
+ $b = if ($existing.LastRunDate) { [datetime]$existing.LastRunDate } else { [datetime]::MinValue }
+ if ($a -gt $b) { $bestBySub[$sid] = $e }
+ }
+ $chosen = @($bestBySub.Values)
+ if ($chosen.Count -eq 0) { $chosen = $csv }
+
+ $coverageSubs = @($chosen | ForEach-Object { "$($_.SubId)" } | Where-Object { $_ } | Select-Object -Unique)
+ $coveredRequested = @($requested | Where-Object { $coverageSubs -contains $_ })
+
+ $dates = @($chosen | ForEach-Object { $_.LastRunDate } | Where-Object { $_ } | Sort-Object -Descending)
+ $freshness = if ($dates.Count -gt 0) { ([datetime]$dates[0]).ToString('yyyy-MM-dd') } else { $null }
+
+ $out.ExportFound = $true
+ $out.Exports = $chosen
+ $out.CoverageSubs = $coverageSubs
+ $out.Freshness = $freshness
+ $out.CoveragePct = if ($subCount -gt 0) { [int][math]::Round((($coveredRequested.Count / [double]$subCount) * 100)) } else { 0 }
+
+ $asOf = if ($freshness) { " as of $freshness" } else { '' }
+ $names = (($chosen | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', ')
+ if ($coveredRequested.Count -ge $subCount) {
+ $out.Recommendation = 'UseExport'
+ $out.Message = "Cost Management export(s) [$names] cover all $subCount requested subscription(s)$asOf. Using export data (fast)."
+ }
+ else {
+ $out.Recommendation = 'UseExportPartial'
+ $out.Message = "Cost Management export(s) [$names] cover $($coveredRequested.Count) of $subCount subscription(s)$asOf. The remaining subscriptions need the live Cost Management API."
+ }
+
+ return $out
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1
new file mode 100644
index 000000000..e621ae092
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1
@@ -0,0 +1,106 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# Escapes a caller-supplied value for safe use inside a single-quoted KQL
+# string literal. KQL uses backslash escapes, so \ and ' must both be escaped
+# or a crafted value could terminate the literal and alter query semantics.
+function ConvertTo-KqlLiteral {
+ param([string]$Value)
+ if ($null -eq $Value) { return '' }
+ return $Value.Replace('\', '\\').Replace("'", "\'")
+}
+
+function Search-AzGraphSafe {
+ param(
+ [Parameter(Mandatory)][string]$Query,
+ [string[]]$Subscription,
+ [int]$First = 1000,
+ [string]$SkipToken,
+ [int]$TimeoutSeconds = 60,
+ [int]$MaxRetries = 2
+ )
+ for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($q, $s, $f, $st)
+ $p = @{ Query = $q; Subscription = $s; First = $f; ErrorAction = 'Stop' }
+ if ($st) { $p['SkipToken'] = $st }
+ $r = Search-AzGraph @p
+ $json = if ($r.Data -and $r.Data.Count -gt 0) {
+ $r.Data | ConvertTo-Json -Depth 20 -Compress
+ }
+ else { '[]' }
+ [PSCustomObject]@{
+ JsonData = $json
+ SkipToken = $r.SkipToken
+ Count = if ($r.Data) { $r.Data.Count } else { 0 }
+ }
+ }).AddArgument($Query).AddArgument($Subscription).AddArgument($First).AddArgument($SkipToken)
+
+ $asyncResult = $ps.BeginInvoke()
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ $result = $null
+ $is429 = $false
+ $isTransient = $false
+ if ($asyncResult.IsCompleted) {
+ try {
+ $raw = $ps.EndInvoke($asyncResult)
+ $wrapper = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ if ($wrapper) {
+ $data = if ($wrapper.JsonData -and $wrapper.JsonData -ne '[]') {
+ $parsed = $wrapper.JsonData | ConvertFrom-Json
+ if ($parsed -is [array]) { $parsed } else { @($parsed) }
+ }
+ else { @() }
+ $result = [PSCustomObject]@{
+ Data = $data
+ SkipToken = $wrapper.SkipToken
+ Count = $wrapper.Count
+ }
+ }
+ if ($ps.Streams.Error.Count -gt 0) {
+ $errMsg = $ps.Streams.Error[0].Exception.Message
+ if ($errMsg -match '429|throttl|Too Many Requests') { $is429 = $true; $result = $null }
+ elseif ($errMsg -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true; $result = $null }
+ elseif (-not $result) { throw $ps.Streams.Error[0].Exception }
+ }
+ }
+ catch {
+ if ($_.Exception.Message -match '429|throttl|Too Many Requests') { $is429 = $true }
+ elseif ($_.Exception.Message -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true }
+ else { $ps.Dispose(); throw }
+ }
+ }
+ else {
+ $ps.Stop()
+ Write-Warning " Resource Graph query timed out after $($TimeoutSeconds)s"
+ }
+
+ $ps.Dispose()
+
+ if (-not ($is429 -or $isTransient)) { return $result }
+ if ($attempt -eq $MaxRetries) { return $result }
+
+ # Throttling backs off harder than a transient server error.
+ $retryAfter = if ($is429) {
+ [math]::Min(10 * [math]::Pow(2, $attempt), 30)
+ }
+ else {
+ [math]::Min(2 * [math]::Pow(2, $attempt), 15)
+ }
+ $friendly = if ($is429) {
+ if (Get-Command Get-NextThrottleMessage -ErrorAction SilentlyContinue) { Get-NextThrottleMessage } else { 'Fetching numbers......' }
+ }
+ else {
+ 'Resource Graph is unavailable - retrying...'
+ }
+ Write-Host " $friendly" -ForegroundColor Yellow
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus $friendly
+ }
+ Wait-WithDispatcher -Milliseconds ($retryAfter * 1000)
+ }
+ return $null
+}
diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1
new file mode 100644
index 000000000..7deb5f37a
--- /dev/null
+++ b/src/powershell/Public/Start-FinOpsMultitool.ps1
@@ -0,0 +1,70 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+<#
+ .SYNOPSIS
+ Launches the Azure FinOps Multitool interactive terminal UI.
+
+ .DESCRIPTION
+ The Start-FinOpsMultitool command launches an interactive terminal UI (TUI) that
+ scans an Azure tenant for cost optimization, governance, and FinOps insights. The
+ tool authenticates to Azure, discovers subscriptions, and runs the scan modules you
+ select - covering cost trends, orphaned resources, idle VMs, tag hygiene, reservation
+ and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly
+ alerts, and policy compliance.
+
+ Results are rendered in the terminal with export options for Excel, CSV, JSON, and
+ Power BI.
+
+ The scan modules are read-only. The TUI runs on PowerShell 5.1+ (Windows) or
+ PowerShell 7+ (cross-platform) and requires the Az modules (Az.Accounts,
+ Az.ResourceGraph, Az.Storage) and Reader access on the target scope.
+
+ .PARAMETER SubscriptionId
+ Optional subscription ID to scope the scan to a single subscription. When omitted,
+ the tool discovers all accessible subscriptions.
+
+ .PARAMETER OutputPath
+ Optional directory for exported result files. Defaults to the tool's working folder.
+
+ .EXAMPLE
+ Start-FinOpsMultitool
+
+ Launches the FinOps Multitool TUI. You will be prompted to authenticate and
+ select the subscriptions and modules to scan.
+
+ .EXAMPLE
+ Start-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000'
+
+ Launches the TUI scoped to a single subscription.
+
+ .LINK
+ https://aka.ms/ftk/Start-FinOpsMultitool
+#>
+function Start-FinOpsMultitool {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Start-FinOpsMultitool launches a read-only interactive scanner and does not modify system state.')]
+ [OutputType([void])]
+ param(
+ [Parameter()]
+ [string]$SubscriptionId,
+
+ [Parameter()]
+ [string]$OutputPath
+ )
+
+ # Locate the Multitool TUI implementation
+ $multitoolRoot = Join-Path -Path $PSScriptRoot -ChildPath '../Private/FinOpsMultitool'
+ $tuiScript = Join-Path -Path $multitoolRoot -ChildPath 'Invoke-FinOpsMultitool.ps1'
+
+ if (-not (Test-Path -Path $tuiScript)) {
+ Write-Error "FinOps Multitool files not found at '$multitoolRoot'. The module installation may be incomplete."
+ return
+ }
+
+ # Dot-source the TUI launcher so Invoke-FinOpsMultitool is defined here, then
+ # invoke it. The TUI imports its own module set (FinOpsMultitool.psm1) on launch,
+ # so it stays self-contained and does not leak $script: state into the module.
+ . $tuiScript
+ Invoke-FinOpsMultitool @PSBoundParameters
+}
diff --git a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1
new file mode 100644
index 000000000..7d9ccb96f
--- /dev/null
+++ b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1
@@ -0,0 +1,212 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+& "$PSScriptRoot/../Initialize-Tests.ps1"
+
+BeforeAll {
+ $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1'
+ Import-Module $script:MultitoolModule -Force
+}
+
+AfterAll {
+ Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue
+}
+
+Describe 'FinOps Hub Kusto provider' {
+
+ Context 'Resolve-FOHubProvider - explicit override' {
+ AfterEach {
+ Remove-Item Env:FINOPS_HUB_KUSTO_URI -ErrorAction SilentlyContinue
+ Remove-Item Env:FINOPS_HUB_KUSTO_DB -ErrorAction SilentlyContinue
+ }
+
+ It 'Resolves a localhost override to KustoLocal with no auth' {
+ $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082'
+ $p = Resolve-FOHubProvider
+ $p.Found | Should -BeTrue
+ $p.Mode | Should -Be 'KustoLocal'
+ $p.UseAuth | Should -BeFalse
+ $p.Database | Should -Be 'Hub'
+ $p.Source | Should -Be 'EnvOverride'
+ }
+
+ It 'Resolves a remote cluster override to Kusto with auth' {
+ $env:FINOPS_HUB_KUSTO_URI = 'https://myhub.eastus.kusto.windows.net'
+ $p = Resolve-FOHubProvider
+ $p.Found | Should -BeTrue
+ $p.Mode | Should -Be 'Kusto'
+ $p.UseAuth | Should -BeTrue
+ }
+
+ It 'Honors a custom database name' {
+ $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082'
+ $env:FINOPS_HUB_KUSTO_DB = 'CustomHub'
+ $p = Resolve-FOHubProvider
+ $p.Database | Should -Be 'CustomHub'
+ }
+ }
+
+ Context 'Resolve-FOHubProvider - discovery and none' {
+ It 'Uses a cluster already discovered on the decision object' {
+ $decision = [PSCustomObject]@{ KustoClusterUri = 'https://disc.westus.kusto.windows.net'; KustoDatabase = 'Hub'; HubVersion = '0.10' }
+ $p = Resolve-FOHubProvider -Decision $decision
+ $p.Found | Should -BeTrue
+ $p.Mode | Should -Be 'Kusto'
+ $p.ClusterUri | Should -Be 'https://disc.westus.kusto.windows.net'
+ $p.Source | Should -Be 'Discovered'
+ }
+
+ It 'Returns None when no override and discovery finds nothing' {
+ InModuleScope FinOpsMultitool {
+ Mock Search-AzGraphSafe { @{ Data = @() } }
+ $p = Resolve-FOHubProvider -Subscriptions @('00000000-0000-0000-0000-000000000000')
+ $p.Found | Should -BeFalse
+ $p.Mode | Should -Be 'None'
+ }
+ }
+
+ It 'Discovers a cluster directly when no decision is passed (TUI path)' {
+ InModuleScope FinOpsMultitool {
+ Mock Search-AzGraphSafe {
+ @{ Data = @(
+ [PSCustomObject]@{ clusterUri = 'https://tui.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '1' }
+ ) }
+ }
+ $p = Resolve-FOHubProvider -Subscriptions @('1')
+ $p.Found | Should -BeTrue
+ $p.Mode | Should -Be 'Kusto'
+ $p.UseAuth | Should -BeTrue
+ $p.ClusterUri | Should -Be 'https://tui.eastus.kusto.windows.net'
+ $p.Source | Should -Be 'Discovered'
+ }
+ }
+ }
+
+ Context 'Get-HubKustoCluster - online discovery' {
+ It 'Discovers a FinOps hub Kusto cluster via Resource Graph' {
+ InModuleScope FinOpsMultitool {
+ Mock Search-AzGraphSafe {
+ @{ Data = @(
+ [PSCustomObject]@{ clusterUri = 'https://ftk.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '11111111-1111-1111-1111-111111111111' }
+ ) }
+ }
+ $c = Get-HubKustoCluster -RequestedSubscriptionIds @('11111111-1111-1111-1111-111111111111')
+ $c | Should -Not -BeNullOrEmpty
+ $c.ClusterUri | Should -Be 'https://ftk.eastus.kusto.windows.net'
+ $c.HubVersion | Should -Be '0.11'
+ }
+ }
+
+ It 'Prefers the cluster in the hub resource group' {
+ InModuleScope FinOpsMultitool {
+ Mock Search-AzGraphSafe {
+ @{ Data = @(
+ [PSCustomObject]@{ clusterUri = 'https://other.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-other'; subscriptionId = '1' }
+ [PSCustomObject]@{ clusterUri = 'https://mine.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '1' }
+ ) }
+ }
+ $c = Get-HubKustoCluster -RequestedSubscriptionIds @('1') -HubResourceGroup 'rg-hub'
+ $c.ClusterUri | Should -Be 'https://mine.eastus.kusto.windows.net'
+ }
+ }
+
+ It 'Returns null when no cluster is found' {
+ InModuleScope FinOpsMultitool {
+ Mock Search-AzGraphSafe { @{ Data = @() } }
+ Get-HubKustoCluster -RequestedSubscriptionIds @('1') | Should -BeNullOrEmpty
+ }
+ }
+ }
+
+ Context 'Get-FOHubCostSummary shape' {
+ It 'Produces a per-subscription cost map matching the converter shape' {
+ InModuleScope FinOpsMultitool {
+ Mock Invoke-FOHubKustoQuery {
+ @{
+ Ok = $true
+ RowCount = 2
+ Error = $null
+ Rows = @(
+ [PSCustomObject]@{ _sub = 'aaaaaaaa-1111-2222-3333-444444444444'; Actual = 123.456; Currency = 'USD' }
+ [PSCustomObject]@{ _sub = 'bbbbbbbb-1111-2222-3333-444444444444'; Actual = 10.0; Currency = 'EUR' }
+ )
+ }
+ }
+ $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false }
+ $map = Get-FOHubCostSummary -Provider $prov
+
+ $map | Should -BeOfType ([hashtable])
+ $map.Keys.Count | Should -Be 2
+ $map['aaaaaaaa-1111-2222-3333-444444444444'].Actual | Should -Be 123.46
+ $map['aaaaaaaa-1111-2222-3333-444444444444'].Forecast | Should -Be 0.0
+ $map['aaaaaaaa-1111-2222-3333-444444444444'].Currency | Should -Be 'USD'
+ $map['bbbbbbbb-1111-2222-3333-444444444444'].Currency | Should -Be 'EUR'
+ }
+ }
+
+ It 'Surfaces a query error instead of throwing' {
+ InModuleScope FinOpsMultitool {
+ Mock Invoke-FOHubKustoQuery { @{ Ok = $false; Rows = @(); RowCount = 0; Error = 'boom' } }
+ $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false }
+ $r = Get-FOHubCostSummary -Provider $prov
+ $r.Error | Should -Be 'boom'
+ }
+ }
+ }
+
+ Context 'Get-FOHubResourceCosts shape' {
+ It 'Produces sorted resource cost objects with the converter properties' {
+ InModuleScope FinOpsMultitool {
+ Mock Invoke-FOHubKustoQuery {
+ @{
+ Ok = $true
+ RowCount = 2
+ Error = $null
+ Rows = @(
+ [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg1'; ResourceType = 'Microsoft.Compute/virtualMachines'; ResourcePath = '/subscriptions/x/rg1/vm1'; Actual = 50.0; Currency = 'USD' }
+ [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg2'; ResourceType = 'Microsoft.Storage/storageAccounts'; ResourcePath = '/subscriptions/x/rg2/sa1'; Actual = 200.0; Currency = 'USD' }
+ )
+ }
+ }
+ $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false }
+ $rows = Get-FOHubResourceCosts -Provider $prov
+
+ @($rows).Count | Should -Be 2
+ $rows[0].PSObject.Properties.Name | Should -Contain 'ResourcePath'
+ $rows[0].PSObject.Properties.Name | Should -Contain 'Forecast'
+ $rows[0].Actual | Should -Be 200.0
+ $rows[0].Forecast | Should -Be 0.0
+ }
+ }
+ }
+
+ Context 'Get-FOHubCostByTag shape' {
+ It 'Derives (untagged) cost per key from the TOTAL sentinel' {
+ InModuleScope FinOpsMultitool {
+ Mock Invoke-FOHubKustoQuery {
+ @{
+ Ok = $true
+ RowCount = 3
+ Error = $null
+ Rows = @(
+ [PSCustomObject]@{ TagKey = '*TOTAL*'; TagValue = '*TOTAL*'; Cost = 1000.0; Currency = 'USD' }
+ [PSCustomObject]@{ TagKey = 'env'; TagValue = 'prod'; Cost = 600.0; Currency = 'USD' }
+ [PSCustomObject]@{ TagKey = 'env'; TagValue = 'dev'; Cost = 300.0; Currency = 'USD' }
+ )
+ }
+ }
+ $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false }
+ $result = Get-FOHubCostByTag -Provider $prov -TagKeys @('env')
+
+ $result.NoTagsFound | Should -BeFalse
+ $result.TagsQueried | Should -Contain 'env'
+ $envEntries = $result.CostByTag['env']
+ ($envEntries | Where-Object { $_.TagValue -eq 'prod' }).Cost | Should -Be 600.0
+ ($envEntries | Where-Object { $_.TagValue -eq 'dev' }).Cost | Should -Be 300.0
+ ($envEntries | Where-Object { $_.TagValue -eq '(untagged)' }).Cost | Should -Be 100.0
+ # Sorted descending: prod (600) first.
+ $envEntries[0].TagValue | Should -Be 'prod'
+ }
+ }
+ }
+}
diff --git a/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 b/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1
new file mode 100644
index 000000000..fc76c244f
--- /dev/null
+++ b/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1
@@ -0,0 +1,218 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# The write-safety gate is a private FinOpsMultitool helper that the toolkit
+# module only lazy-loads behind the TUI, so dot-source it directly. It must go in
+# BeforeAll: top-level code runs only during Pester discovery, so functions
+# dot-sourced there are gone by the run phase.
+BeforeAll {
+ . "$PSScriptRoot/../../Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1"
+}
+
+Describe 'FinOps Multitool write-safety gate' {
+
+ BeforeEach {
+ # Every case starts from a known policy and empty in-process state.
+ $env:FINOPS_AUDIT_LOG = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-writegate-$([guid]::NewGuid().ToString('N')).log"
+ $env:FINOPS_WRITE_MAX_IMPACT = $null
+ $env:FINOPS_WRITE_MAX_PER_WINDOW = $null
+ $env:FINOPS_PROTECTED_TAGS = $null
+ $env:FINOPS_PROTECTED_RGS = $null
+ $env:FINOPS_PROTECTED_SUBS = $null
+ # Note: the gate's token store and write history are per-process and
+ # cannot be reset from here, so each case must stand on its own.
+ }
+
+ AfterEach {
+ if ($env:FINOPS_AUDIT_LOG -and (Test-Path -LiteralPath $env:FINOPS_AUDIT_LOG)) {
+ Remove-Item -LiteralPath $env:FINOPS_AUDIT_LOG -Force -ErrorAction SilentlyContinue
+ }
+ $env:FINOPS_WRITE_MODE = $null
+ $env:FINOPS_AUDIT_LOG = $null
+ }
+
+ Context 'ReadOnly mode (the default)' {
+
+ It 'Should default to ReadOnly when FINOPS_WRITE_MODE is unset' {
+ $env:FINOPS_WRITE_MODE = $null
+ (Initialize-FinOpsWritePolicy).Mode | Should -Be 'ReadOnly'
+ }
+
+ It 'Should fall back to ReadOnly when FINOPS_WRITE_MODE is not a known value' {
+ $env:FINOPS_WRITE_MODE = 'YOLO'
+ (Initialize-FinOpsWritePolicy).Mode | Should -Be 'ReadOnly'
+ }
+
+ It 'Should block a dry-run preview' {
+ $env:FINOPS_WRITE_MODE = 'ReadOnly'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r'
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should block apply=true' {
+ $env:FINOPS_WRITE_MODE = 'ReadOnly'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply
+ $d.Decision | Should -Be 'Blocked'
+ $d.Reason | Should -Match 'ReadOnly'
+ }
+ }
+
+ Context 'Interactive mode' {
+
+ BeforeEach {
+ $env:FINOPS_WRITE_MODE = 'Interactive'
+ Initialize-FinOpsWritePolicy | Out-Null
+ }
+
+ It 'Should preview and issue a token on a dry run' {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r'
+ $d.Decision | Should -Be 'Preview'
+ $d.ConfirmationToken | Should -Not -BeNullOrEmpty
+ $d.RequiresToken | Should -BeFalse
+ }
+
+ It 'Should proceed on apply=true without a token' {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply
+ $d.Decision | Should -Be 'Proceed'
+ }
+ }
+
+ Context 'Enforced mode' {
+
+ BeforeEach {
+ $env:FINOPS_WRITE_MODE = 'Enforced'
+ Initialize-FinOpsWritePolicy | Out-Null
+ }
+
+ It 'Should flag that a token is required on preview' {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r'
+ $d.Decision | Should -Be 'Preview'
+ $d.RequiresToken | Should -BeTrue
+ $d.ConfirmationToken | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should block apply=true with no token' {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply
+ $d.Decision | Should -Be 'Blocked'
+ $d.Reason | Should -Match 'Enforced'
+ }
+
+ It 'Should block apply=true with a bogus token' {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken 'not-a-real-token'
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should proceed with the token from the matching dry run' {
+ $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r'
+ $apply = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken
+ $apply.Decision | Should -Be 'Proceed'
+ }
+
+ It 'Should reject a token on second use' {
+ $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r'
+ $first = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken
+ $second = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken
+ $first.Decision | Should -Be 'Proceed'
+ $second.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should reject a token issued for a different resource' {
+ $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/resource-A'
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/resource-B' -Apply -ConfirmationToken $preview.ConfirmationToken
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should reject a token issued for a different operation' {
+ $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Deallocate' -ResourceId '/subscriptions/s/rg/r'
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken
+ $d.Decision | Should -Be 'Blocked'
+ }
+ }
+
+ Context 'Guardrails' {
+
+ BeforeEach {
+ $env:FINOPS_WRITE_MODE = 'Interactive'
+ }
+
+ It 'Should block a resource carrying a protected tag by default' {
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Tags @{ 'DoNotDelete' = 'true' } -Apply
+ $d.Decision | Should -Be 'Blocked'
+ $d.GuardrailViolations.Count | Should -BeGreaterThan 0
+ }
+
+ It 'Should block a protected subscription' {
+ $env:FINOPS_PROTECTED_SUBS = '00000000-0000-0000-0000-000000000001'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -SubscriptionId '00000000-0000-0000-0000-000000000001' -Apply
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should block a resource group matching a protected pattern' {
+ $env:FINOPS_PROTECTED_RGS = 'prod-*'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -ResourceGroup 'prod-payments' -Apply
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should block when estimated impact exceeds the cap' {
+ $env:FINOPS_WRITE_MAX_IMPACT = '100'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -EstimatedMonthlyImpact 500 -Apply
+ $d.Decision | Should -Be 'Blocked'
+ }
+
+ It 'Should allow an impact below the cap' {
+ $env:FINOPS_WRITE_MAX_IMPACT = '100'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -EstimatedMonthlyImpact 5 -Apply
+ $d.Decision | Should -Be 'Proceed'
+ }
+
+ It 'Should block once the blast-radius cap is reached' {
+ $env:FINOPS_WRITE_MAX_PER_WINDOW = '3'
+ Initialize-FinOpsWritePolicy | Out-Null
+ # Write history is per-process and shared across cases, so assert the
+ # transition to Blocked rather than a fixed attempt number.
+ $blocked = $null
+ for ($n = 1; $n -le 6; $n++) {
+ $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId "/subscriptions/s/rg/r$n" -Apply
+ if ($d.Decision -eq 'Blocked') { $blocked = $d; break }
+ }
+ $blocked | Should -Not -BeNullOrEmpty
+ ($blocked.GuardrailViolations -join ' ') | Should -Match 'Blast-radius'
+ }
+
+ It 'Should enforce guardrails even in Enforced mode with a valid token' {
+ $env:FINOPS_WRITE_MODE = 'Enforced'
+ $env:FINOPS_PROTECTED_RGS = 'prod-*'
+ Initialize-FinOpsWritePolicy | Out-Null
+ $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -ResourceGroup 'prod-payments'
+ $preview.Decision | Should -Be 'Blocked'
+ }
+ }
+
+ Context 'Audit trail' {
+
+ It 'Should append an entry for a blocked write' {
+ $env:FINOPS_WRITE_MODE = 'ReadOnly'
+ Initialize-FinOpsWritePolicy | Out-Null
+ Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply | Out-Null
+ Test-Path -LiteralPath $env:FINOPS_AUDIT_LOG | Should -BeTrue
+ (Get-Content -LiteralPath $env:FINOPS_AUDIT_LOG -Raw) | Should -Match 'blocked'
+ }
+
+ It 'Should record preview and apply events' {
+ $env:FINOPS_WRITE_MODE = 'Interactive'
+ Initialize-FinOpsWritePolicy | Out-Null
+ Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' | Out-Null
+ Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply | Out-Null
+ $log = Get-Content -LiteralPath $env:FINOPS_AUDIT_LOG -Raw
+ $log | Should -Match 'preview'
+ $log | Should -Match 'apply'
+ }
+ }
+}
diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
new file mode 100644
index 000000000..4caa7258f
--- /dev/null
+++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
@@ -0,0 +1,77 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+& "$PSScriptRoot/../Initialize-Tests.ps1"
+
+InModuleScope 'FinOpsToolkit' {
+ Describe 'Start-FinOpsMultitool' {
+
+ Context 'Command availability' {
+ It 'Should be exported as a public command' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit' -ErrorAction SilentlyContinue
+ $cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have CmdletBinding attribute' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit'
+ $cmd.CmdletBinding | Should -BeTrue
+ }
+ }
+
+ Context 'File dependencies' {
+ It 'Should have the Multitool TUI launcher' {
+ $tuiPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1'
+ Test-Path -Path $tuiPath | Should -BeTrue
+ }
+
+ It 'Should have the Multitool module loader' {
+ $psm1Path = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/FinOpsMultitool.psm1'
+ Test-Path -Path $psm1Path | Should -BeTrue
+ }
+
+ It 'Should have all scanner module files' {
+ $modulesPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/modules'
+ $modules = Get-ChildItem -Path $modulesPath -Filter '*.ps1'
+ # Exact count so a deleted scanner fails the build instead of
+ # silently passing a loose lower bound.
+ $modules.Count | Should -Be 37
+ }
+
+ It 'Should dot-source every scanner module file from the loader' {
+ $psm1Path = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/FinOpsMultitool.psm1'
+ $modulesPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/modules'
+ $loader = Get-Content -Path $psm1Path -Raw
+ foreach ($m in (Get-ChildItem -Path $modulesPath -Filter '*.ps1')) {
+ $loader | Should -Match ([regex]::Escape($m.Name))
+ }
+ }
+ }
+
+ Context 'Behavior' {
+ It 'Should write an error when the TUI launcher is missing' {
+ Mock Test-Path { $false }
+ { Start-FinOpsMultitool -ErrorAction Stop } | Should -Throw '*installation may be incomplete*'
+ }
+
+ It 'Should not attempt to launch the TUI when the launcher is missing' {
+ Mock Test-Path { $false }
+ # Returns instead of dot-sourcing; a throw here would be a
+ # CommandNotFoundException for the never-loaded TUI function.
+ Start-FinOpsMultitool -ErrorAction SilentlyContinue
+ Should -Invoke Test-Path -Times 1 -Exactly
+ }
+ }
+
+ Context 'Parameters' {
+ It 'Should expose an optional SubscriptionId parameter' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit'
+ $cmd.Parameters.ContainsKey('SubscriptionId') | Should -BeTrue
+ }
+
+ It 'Should expose an optional OutputPath parameter' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit'
+ $cmd.Parameters.ContainsKey('OutputPath') | Should -BeTrue
+ }
+ }
+ }
+}
diff --git a/src/templates/agent-skills/anomaly-investigation/SKILL.md b/src/templates/agent-skills/anomaly-investigation/SKILL.md
new file mode 100644
index 000000000..cf1e4ab20
--- /dev/null
+++ b/src/templates/agent-skills/anomaly-investigation/SKILL.md
@@ -0,0 +1,51 @@
+---
+name: anomaly-investigation
+description: Use when a cost spike, anomaly alert, or unexpected charge needs root-cause analysis — drilling from a total-cost jump down to the specific service, resource, region, or change that caused it. Picks up after detection (the "what") to deliver the "why" and the fix.
+license: MIT
+compatibility: Requires cost data (Cost Management or a FinOps hub) at resource granularity and, ideally, Azure Activity Log read access to correlate changes. Pairs with the finops-multitool skill and the finops-toolkit KQL skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+allowed-tools: az pwsh
+---
+
+# Cost anomaly investigation
+
+Detection tells you a cost moved; this skill tells you *why* and what to do. It's the root-cause triage that runs after an anomaly alert fires or a budget variance appears.
+
+## When to use this skill
+
+Use it when the user reports a spike, an unexpected bill, an anomaly alert, or "why did cost jump." Confirm/quantify the anomaly first (anomaly alerts, cost trend, `cost-anomaly-detection.kql`), then drill here. For *setting up* detection/alerts, use `forecasting-budgeting` or the `azure-cost-management` anomaly-alerts skill instead.
+
+## Root-cause drill-down
+
+Narrow the spike one dimension at a time until a single driver remains:
+
+1. **When** — pinpoint the day the cost stepped up. A sharp step = a discrete change; a ramp = growing usage.
+2. **What service** — group the delta by `ServiceName`; find the service contributing most of the increase.
+3. **What resource** — within that service, group by `ResourceName` / resource group.
+4. **What changed** — for the suspect resource and date, correlate with the **Azure Activity Log** (create/scale/SKU-change events) and with commitment expiries.
+5. **Rate vs volume** — did usage rise (volume) or did the unit price rise / a discount drop (rate)? Compare `EffectiveCost` movement to usage quantity.
+
+## Common spike signatures
+
+| Signature | Likely cause |
+|-----------|--------------|
+| Step up on a specific day, one resource | Scale-up, SKU change, or tier upgrade — check Activity Log |
+| Gradual ramp across many resources | Organic growth or a rollout — usually expected |
+| Spike with no usage change | A reservation/savings plan expired → rate went to on-demand (check ESR, commitment utilization) |
+| New resource type appears | Net-new deployment, possibly untagged/unowned |
+| Data-transfer / egress jump | Cross-region traffic, new integration, data exfil pattern — investigate |
+| Spike then return to baseline | One-off job, batch run, or test left running |
+| Sustained jump in a shared service | Allocation/shared-cost shift — see `cost-allocation` |
+
+## Closing the loop
+
+For each confirmed anomaly deliver: **driver** (the specific resource/change), **$ impact** (one-time vs recurring), **owner**, **action** (rightsize / delete / re-commit / accept-as-expected), and **prevention** (a budget alert, policy, or tag so it's caught earlier next time). An anomaly that's explained but not prevented will recur.
+
+## Hand-offs
+
+- Confirm/quantify the anomaly → `finops-multitool` (anomaly alerts, cost trend).
+- Spike caused by expired commitment → `rate-optimization-portfolio`.
+- Prevent recurrence → `forecasting-budgeting` (alerts) or `azure-policy-governance` (guardrails).
+- Report it → `finops-reporting`.
diff --git a/src/templates/agent-skills/azure-policy-governance/SKILL.md b/src/templates/agent-skills/azure-policy-governance/SKILL.md
new file mode 100644
index 000000000..69175fe93
--- /dev/null
+++ b/src/templates/agent-skills/azure-policy-governance/SKILL.md
@@ -0,0 +1,62 @@
+---
+name: azure-policy-governance
+description: Use when the user wants to enforce cost governance with Azure Policy — require or inherit tags, restrict regions/SKUs/resource types, audit untagged resources, or generate Bicep/ARM policy assignments. Pairs with the finops-multitool policy scans to close governance gaps.
+license: MIT
+compatibility: Requires Azure CLI authentication and Resource Policy Contributor (or equivalent) to assign policy. Read-only auditing needs only Reader. Verify built-in policy definition IDs against Microsoft Learn before generating templates.
+metadata:
+ author: microsoft
+ version: "1.0"
+allowed-tools: az pwsh
+---
+
+# Azure Policy for cost governance
+
+Enforce the guardrails that make FinOps allocation and waste-control durable: tag requirements, tag inheritance, region/SKU restrictions, and audit policies. This skill turns governance gaps into deployable policy.
+
+## When to use this skill
+
+Use it when the user wants to enforce or audit tagging, restrict what can be deployed, or asks for "a policy" to back up a FinOps recommendation. Run policy inventory and policy recommendations from the `finops-multitool` skill first to see what's already assigned and where the gaps are, then generate policy here.
+
+## Workflow
+
+1. **Inventory** — policy inventory (existing assignments, scopes, effects, compliance).
+2. **Gap analysis** — policy recommendations (missing tagging/region/SKU guardrails).
+3. **Verify definition IDs** — built-in policy IDs change rarely but must be confirmed. Use the Microsoft Learn MCP / docs before emitting any ID into a template. Do not ship an ID from memory.
+4. **Generate** — produce Bicep or ARM for the assignment(s), parameterized and scoped.
+5. **Stage effects** — deploy as `Audit`/`AuditIfNotExists` first, review compliance, then escalate to `Deny`/`Modify`. Never lead with `Deny` on an existing environment.
+
+## Core cost-governance policies
+
+| Goal | Built-in policy (verify ID before use) | Effect |
+|------|----------------------------------------|--------|
+| Require a tag on resource groups | "Require a tag on resource groups" | Deny |
+| Require a tag on resources | "Require a tag on resources" | Deny |
+| Inherit a tag from the resource group | "Inherit a tag from the resource group" | Modify |
+| Audit missing tag | "Require a tag and its value on resources" (audit variant) | Audit |
+| Allowed locations | "Allowed locations" | Deny |
+| Allowed resource types | "Allowed resource types" | Deny |
+| Allowed VM SKUs | "Allowed virtual machine size SKUs" | Deny |
+
+`Modify` and `DeployIfNotExists` policies require a managed identity on the assignment with rights to remediate. Generate a remediation task after assignment so existing resources are brought into compliance, not just new ones.
+
+## Effect sequencing (safe rollout)
+
+```
+Audit → review compliance → Modify/Append (auto-fix) → remediate existing → Deny (block new violations)
+```
+
+Apply the same tag key across all three layers (require on RG, inherit to resources, deny new untagged) so the allocation model from the `cost-allocation` skill is enforced end to end.
+
+## Generating templates
+
+When asked for Bicep:
+- Parameterize the tag name, allowed values/locations/SKUs, scope, and effect.
+- Set `enforcementMode` to `DoNotEnforce` for a dry run when the user wants to preview impact.
+- Use the Bicep MCP tools to validate the `Microsoft.Authorization/policyAssignments` schema and run diagnostics before presenting the file.
+- Output one assignment per concern; don't bundle unrelated guardrails into a single assignment.
+
+## Hand-offs
+
+- Decide which tags to enforce → `cost-allocation` skill.
+- See current gaps → `finops-multitool` policy scans.
+- Report compliance trend visually → `power-bi-finops` (governance report) or `azure-workbooks-finops`.
diff --git a/src/templates/agent-skills/azure-workbooks-finops/SKILL.md b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md
new file mode 100644
index 000000000..8095aac17
--- /dev/null
+++ b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: azure-workbooks-finops
+description: Use when the user wants to deploy, interpret, or customize the FinOps toolkit Azure Monitor workbooks (Governance and Optimization), build a workbook from cost/resource data, or troubleshoot a workbook that shows no data. For Azure Monitor workbooks specifically — not Power BI.
+license: MIT
+compatibility: Requires Azure access with Reader (to view) or Workbook Contributor (to save) and, for some tiles, Azure Resource Graph and Cost Management read access. Pairs with the finops-multitool skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+allowed-tools: az pwsh
+---
+
+# Azure Monitor workbooks for FinOps
+
+The FinOps toolkit ships Azure Monitor workbooks that surface governance and optimization findings directly in the Azure portal — no external BI tool required. This skill covers deploying, reading, and customizing them.
+
+## When to use this skill
+
+Use it when the user mentions Azure Monitor workbooks, the Governance or Optimization workbook, in-portal cost/waste views, or wants a workbook (not Power BI). For an external dashboard use `power-bi-finops`; for headless data use the `finops-multitool` scans.
+
+## The two toolkit workbooks
+
+| Workbook | Surfaces | Backed by |
+|----------|----------|-----------|
+| **Governance** | Tag coverage, policy compliance, resource hygiene, allocation readiness, naming/region drift | Azure Resource Graph |
+| **Optimization** | Idle/orphaned resources, rightsizing, commitment coverage, Advisor cost recommendations, waste | Resource Graph + Advisor + Cost Management |
+
+Both are deployed from the toolkit and run live against the selected subscriptions/scope — they reflect current state, not a snapshot.
+
+## Deploying
+
+Deploy via the toolkit's workbook template (ARM/Bicep) into a resource group, then open it under **Azure Monitor → Workbooks**. Scope the workbook with the subscription/resource-group parameters at the top. For deployment specifics defer to the toolkit's workbook docs; the build packaging copies the workbook assets under the toolkit release.
+
+## "Workbook shows no data" triage
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| All tiles empty | Scope parameter set to a subscription with no matching resources | Re-select subscription(s) at the top |
+| Cost tiles empty, ARG tiles fine | Missing Cost Management reader at the chosen scope | Grant Cost Management Reader |
+| Advisor tiles empty | Advisor hasn't generated recommendations yet | Wait for Advisor refresh; confirm category = Cost |
+| Permission error on a tile | Reader missing on a child subscription | Add Reader at management-group scope |
+| Slow load across many subs | Resource Graph fan-out | Narrow the scope or split the workbook run |
+
+## Customizing
+
+- Workbook tiles are Resource Graph (KQL-for-ARG) or Azure Monitor queries — edit the query behind a tile to change what it shows.
+- ARG query language overlaps with the patterns in `azure-orphaned-resources` (in the `azure-cost-management` skill) — reuse those queries for new waste tiles.
+- Add a parameter for tag key/value to make governance tiles allocation-aware (ties into the `cost-allocation` skill).
+- Save customized workbooks as a new shared workbook so toolkit upgrades don't overwrite them.
+
+## Workbook vs Power BI — when to use which
+
+- **Workbook** — live, in-portal, RBAC-scoped, zero extra cost, good for ops/governance teams already in Azure.
+- **Power BI** — richer visuals, scheduled refresh, shareable outside the portal, better for exec reporting and large historical analysis (use a FinOps hub source). See `power-bi-finops`.
+
+## Hand-offs
+
+- Same findings as data/JSON → `finops-multitool` scans.
+- Richer/historical visuals → `power-bi-finops`.
+- Enforce what the Governance workbook flags → `azure-policy-governance`.
diff --git a/src/templates/agent-skills/cost-allocation/SKILL.md b/src/templates/agent-skills/cost-allocation/SKILL.md
new file mode 100644
index 000000000..8d82cc6ed
--- /dev/null
+++ b/src/templates/agent-skills/cost-allocation/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: cost-allocation
+description: Use when the user wants to design showback or chargeback, allocate shared costs, build a tagging strategy for cost accountability, map spend to teams/products/cost centers, split shared platform costs, or model a financial hierarchy from billing and tag data.
+license: MIT
+compatibility: Requires read access to cost data (Cost Management scope or a FinOps hub) and resource tags. Pairs with the finops-multitool skill (tag and cost-by-tag scans) and the azure-policy-governance skill for enforcement.
+metadata:
+ author: microsoft
+ version: '1.0'
+---
+
+# Cost allocation
+
+Allocate Azure cost to the teams, products, and cost centers that own it — the foundation of showback and chargeback. This skill covers tag strategy, shared-cost splitting, financial hierarchy mapping, and allocation readiness.
+
+## When to use this skill
+
+Use it when the user mentions showback, chargeback, allocation, cost centers, "who owns this spend", splitting shared costs, or building a tag strategy for accountability. For raw tag coverage numbers, run the `finops-multitool` scans first (tag inventory, tag recommendations, cost by tag) and bring the results here to design the model.
+
+## Allocation readiness checklist
+
+1. **Coverage** — what % of cost carries the allocation tag(s)? Below ~95% means material spend is unallocated. Use tag inventory.
+2. **Consistency** — no casing or spelling drift in tag keys/values (`CostCenter` vs `costcenter`, `managed_by` vs `managedBy`). Use tag recommendations.
+3. **Cost dimension** — the allocation tag must be enabled as a cost-allocation dimension in Cost Management, or tag-dimensioned cost data will be empty even when the tags exist.
+4. **Inheritance** — resources that can't be tagged directly (or are missed) should inherit from the resource group via Azure Policy. See the `azure-policy-governance` skill.
+
+## Tagging strategy
+
+Anchor on the Cloud Adoption Framework resource-tagging standard. The seven CAF-aligned tags map cleanly to allocation:
+
+| Tag | Allocation role |
+| ---------------------------------- | --------------------------------------------- |
+| `CostCenter` | Primary chargeback dimension (finance ledger) |
+| `BusinessUnit` | Org rollup |
+| `ApplicationName` / `WorkloadName` | Product / service showback |
+| `OpsTeam` | Operational ownership |
+| `Criticality` | Prioritization, not allocation |
+| `DataClassification` | Compliance, not allocation |
+
+Reference: https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging
+
+Pick **one** authoritative allocation key (usually `CostCenter`) and enforce it everywhere; use the others for rollup and filtering.
+
+## Shared cost splitting
+
+Costs that no single team owns (shared platform, networking, management tooling, support, marketplace, unallocated remainder) must be distributed. Choose a split method per shared pool:
+
+| Method | How | Use when |
+| ------------------ | --------------------------------------------------- | -------------------------------------------------------------------- |
+| **Proportional** | Split in ratio to each team's direct/allocated cost | Default; "you pay for shared services in proportion to what you use" |
+| **Even** | Equal share across N teams | Small, fixed set of consumers |
+| **Fixed / manual** | Hard-coded percentages | Contractual or negotiated splits |
+| **Usage-based** | Split by a usage metric (vCPU-hours, GB, requests) | A real consumption signal exists |
+
+Document the rule, the source pool, and the target dimension so the allocation is reproducible and auditable.
+
+## Financial hierarchy
+
+Map raw billing + tags into a reporting hierarchy: **Billing account → Billing profile / EA enrollment → Invoice section / department → Subscription → Resource group → Tag (CostCenter / App)**. In FinOps hub data, the `cost-by-financial-hierarchy.kql` catalog query (see the `finops-toolkit` skill) already produces this shape — start there rather than rebuilding it.
+
+## Showback vs chargeback
+
+- **Showback** — report allocated cost to each team for visibility; no money moves. Start here.
+- **Chargeback** — actually bill teams' budgets. Only attempt once coverage is high, splits are agreed, and the data is trusted, or it will erode trust on day one.
+
+## Hand-offs
+
+- Tag coverage / drift numbers → `finops-multitool` skill.
+- Enforce tags and inheritance → `azure-policy-governance` skill.
+- Visualize allocation → `power-bi-finops` skill (governance / cost-summary reports).
+- Express allocation efficiency as KPIs → `unit-economics` skill.
diff --git a/src/templates/agent-skills/cost-data-source/SKILL.md b/src/templates/agent-skills/cost-data-source/SKILL.md
new file mode 100644
index 000000000..9df1004ee
--- /dev/null
+++ b/src/templates/agent-skills/cost-data-source/SKILL.md
@@ -0,0 +1,111 @@
+---
+name: cost-data-source
+description: This skill should be used before any spend question that would call the FinOps multitool cost tools — "what's my cost", "current spend", "top resources by cost", "cost by tag", "this month's bill", "where is the money going", or any "cost scan". It decides whether to read from a FinOps Hub (its Kusto database or storage export) or the live Cost Management API, warns the user before a slow API scan, and supports chunking large tenants for incremental progress. Use it to keep cost scans fast and the session engaging instead of blocking on long API runs.
+license: MIT
+compatibility: Requires the finops-multitool skill and an authenticated Azure session (Connect-AzAccount). The hub Kusto path needs read access to the FinOps Hub Azure Data Explorer / Fabric cluster (or a reachable ftklocal emulator); the storage-reader fallback needs Storage Blob Data Reader on the hub storage account. The cost tools this skill routes are read-only.
+metadata:
+ author: microsoft
+ version: '1.1'
+---
+
+# Cost data source routing
+
+Cost questions can be answered from a **FinOps Hub** or from the **live Cost Management API** (real-time, but slow at scale because it queries per subscription). When a hub is used, the tool picks the path automatically:
+
+- **Hub Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) — the scalable path. Aggregation runs in the engine and only summaries return, so it handles large datasets (tens of GB / hundreds of millions of rows) without loading rows.
+- **Hub storage reader** (FOCUS parquet/CSV in storage) — a small-dataset convenience fallback used when no Kusto cluster is reachable. Rows are aggregated in PowerShell.
+
+This skill decides hub vs API so cost scans stay fast and the session stays interactive. The hub-vs-storage-vs-Kusto choice within the hub is automatic — you don't pick it.
+
+This routing applies **only** to the spend-breakdown tools that read from a hub:
+
+- cost data (current month actuals per subscription)
+- resource costs (top resources by cost)
+- cost by tag (spend by tag key/value)
+
+All other cost-family tools — cost trend, budget status, anomaly alerts, reservation advice, commitment utilization, savings realized — are **not** derivable from cost exports and always run on the live API. Governance and optimization tools are unaffected.
+
+## Protocol: detect first, then decide
+
+For any spend question that maps to one of the three tools above, do this **before** calling the cost tool:
+
+1. Call the data-source check (pass `subscriptionId` if the user scoped to one subscription). It returns a decision object describing whether a hub was found, whether it is readable, what it covers, how fresh it is, and how long the live API path would take.
+2. Branch on the `Recommendation` field. Never silently run a slow API scan — warn and ask first.
+
+### Decision object fields
+
+| Field | Meaning |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `HubFound` | Whether a FinOps Hub / cost export exists in scope |
+| `Readable` | Whether the export can actually be read with the current identity |
+| `KustoClusterUri` | The hub's Azure Data Explorer / Fabric cluster URI when discovered (the scalable engine path). Empty when only the storage reader is available. |
+| `ReadBlocker` | Why it is not readable: `NoRbac`, `NetworkDenied`, `PublicAccessDisabled`, `DependencyMissing`, `Unknown`, or `None` |
+| `RemediationHint` | The concrete fix for the blocker |
+| `CoveragePct` | Share of the requested subscriptions the hub covers |
+| `Freshness` | Date of the newest export data (use this as the "as of" label) |
+| `EstimatedApiSeconds` | Rough wall-clock estimate for the live API path over the requested scope |
+| `Recommendation` | `UseHub`, `UseHubPartial`, `FixAccessThenHub`, or `UseApi` |
+| `Message` | A short human summary you can relay |
+
+## Branch behaviours
+
+**`UseHub` — readable hub covers the scope.**
+Call the cost tool with `dataSource: "hub"`. State the result is from the hub and label it "as of ``" — no need to ask. The tool serves it from the hub Kusto database when a cluster is available (engine-side aggregation, scalable) or the storage reader otherwise; the `source` field on the result tells you which. The hub returns billed actuals only; if the user explicitly needs a live forecast, call with `dataSource: "api"`.
+
+**`UseHubPartial` — readable hub, but it covers only some subscriptions.**
+Tell the user the coverage (e.g., "the hub covers 1 of 4 subscriptions, ~25%"). Ask which they want:
+
+- the hub for the covered subscriptions only (fast, partial), or
+- the live API for full coverage (slower — give the `EstimatedApiSeconds` estimate).
+ Then call the tool with `dataSource: "hub"` or `dataSource: "api"` accordingly.
+
+**`FixAccessThenHub` — a hub exists but is not readable.**
+Name the blocker and its fix from `ReadBlocker` / `RemediationHint` (for example, "you have a hub but lack Storage Blob Data Reader on its storage account — granting that role enables the storage reader; or point at the hub's Kusto cluster, which doesn't need storage access"). Ask whether to:
+
+- fix access and retry the hub, or
+- proceed now on the live API (give the `EstimatedApiSeconds` estimate).
+
+**`UseApi` — no usable hub in scope.**
+There is no hub path. Relay the `EstimatedApiSeconds` estimate and ask before running a long scan. If the estimate is large, offer to chunk (see below) so results stream in. Once the user agrees, call the tool with `dataSource: "api"`.
+
+## Consent before slow scans
+
+Treat any live-API cost scan with a non-trivial `EstimatedApiSeconds` as something to confirm first. Lead with the estimate ("a live cost scan across these N subscriptions will take roughly M seconds"). Don't fire a long scan unannounced — the user's priority is a fast, informative session.
+
+## Chunking large tenants
+
+When the live API path is the only option and the scope is large, chunk it so progress is visible:
+
+1. Split the subscription list into batches.
+2. Call the cost tool once per batch, passing `subscriptionIds` (an array) for that batch and `dataSource: "api"`.
+3. After each batch, report incremental progress and a running total — e.g., "batch 2 of 5 done, $42,100 so far".
+
+`subscriptionIds` overrides `subscriptionId` when both are present. The detection step needs to run only once per scope; reuse its decision across the batches.
+
+## Reading the result
+
+Each cost-tool result carries a `source` field:
+
+- `source: "FinOpsHubKusto"` — served from the hub's Kusto database (Azure Data Explorer / Fabric / ftklocal), aggregated in the engine. The scalable path. Use the `asOf` and `coveragePct` fields when summarizing, and lead with "as of ``".
+- `source: "FinOpsHub"` — served from the hub storage reader (small-dataset path). Same `asOf` / `coveragePct` handling. The `note` field flags that forecast is excluded.
+- `source: "CostManagementExport"` — served from a readable Cost Management CSV export (small-dataset path).
+- `source: "LiveApi"` — live Cost Management API, real-time.
+
+Always tell the user which source the numbers came from and, for hub data, how fresh it is. If a hub read was requested with `dataSource: "hub"` but neither a Kusto cluster nor a readable export was available, the tool returns an error naming the blocker — fall back by re-calling with `dataSource: "api"` after confirming with the user.
+
+## Quick reference
+
+| `Recommendation` | Action | Ask the user? |
+| ------------------ | --------------------------------------------------------------- | ------------- |
+| `UseHub` | Call tool with `dataSource: "hub"`, label "as of ``" | No |
+| `UseHubPartial` | Offer hub (partial) vs API (full + estimate) | Yes |
+| `FixAccessThenHub` | Name blocker + fix; offer retry vs API (estimate) | Yes |
+| `UseApi` | Give estimate; offer chunking; run on `dataSource: "api"` | Yes |
+
+## Prerequisites
+
+- An authenticated Azure session (`az login`, or `Connect-AzAccount` for the terminal UI).
+- An authenticated Azure session is required (`Connect-AzAccount`).
+- The scalable hub path needs read access to the FinOps Hub Kusto database — an Azure Data Explorer / Fabric cluster (discovered automatically), or a reachable ftklocal emulator via `FINOPS_HUB_KUSTO_URI`.
+- The storage-reader fallback additionally needs **Storage Blob Data Reader** on the hub storage account. Without any hub path, the data-source check reports the blocker and the live API is used instead.
+- The cost tools this skill routes are read-only.
diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md
new file mode 100644
index 000000000..1cf346717
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/SKILL.md
@@ -0,0 +1,121 @@
+---
+name: finops-multitool
+description: This skill should be used when the user asks to "scan for cost savings", "find orphaned resources", "find idle VMs", "check Azure Hybrid Benefit", "review tags", "tag coverage", "tag recommendations", "policy coverage", "cost by tag", "cost trend", "top resources by cost", "reservation recommendations", "commitment utilization", "realized savings", "budget status", "cost anomaly alerts", "Advisor cost recommendations", "billing structure", "contract info", or run a "FinOps assessment", "FinOps scan", or "cost optimization scan". Also use it proactively whenever the conversation turns to Azure cost, waste, savings, governance, or FinOps health and live data would answer the question better than a general explanation.
+license: MIT
+compatibility: Requires an authenticated Azure session (az login, or Connect-AzAccount for PowerShell) with at least Reader access on the target scope. Everything in this skill is read-only. Remediation is deliberately out of scope - use the FinOps multitool terminal UI (Start-FinOpsMultitool, from the FinOpsToolkit PowerShell module) which gates every write behind a preview and confirmation.
+metadata:
+ author: microsoft
+ version: '2.0'
+allowed-tools: az pwsh
+---
+
+# FinOps multitool
+
+This is the routing hub for FinOps investigations. It answers "what should I look at next" rather than "how do I call this API" - it decides which investigation fits the question, tells you how to gather the data, warns you where the raw numbers mislead, and hands off to the skill that turns a finding into a decision.
+
+Two ways to gather the data:
+
+- **Interactively** - `Start-FinOpsMultitool` launches a terminal UI that runs 30 read-only scan modules, renders the results, and exports them. Best when a person wants the full picture, and the only supported path for remediation.
+- **Directly** - query Azure Resource Graph, Cost Management, Advisor, Monitor, or a FinOps hub yourself with `az` or an Azure MCP server. Best when answering one question inside a conversation. This skill carries the queries and the interpretation rules.
+
+Prefer the direct path for a single question. Suggest the terminal UI when the user wants a full assessment or intends to act on the findings.
+
+## Always confirm scope first
+
+Findings are worthless if they came from the wrong tenant. Before reporting anything:
+
+```bash
+az account show --query "{tenant:tenantId, subscription:name, id:id}" -o json
+az account list --query "length(@)" -o tsv
+```
+
+Many accounts can see several tenants and hundreds of subscriptions while queries run against only the active one. State the tenant and subscription you scanned, and confirm it's the intended scope before the user acts on the numbers.
+
+Scope every query explicitly when the user only cares about one subscription. An unscoped tenant-wide scan across hundreds of subscriptions is slow and usually not what was asked for.
+
+## Investigation routing
+
+| Question | Investigation | Where the detail lives |
+| --------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------- |
+| Where am I wasting money? | Orphaned resources, then idle VMs | [references/waste-detection.md](references/waste-detection.md) |
+| Are stopped VMs still costing me? | Orphaned resources | [references/waste-detection.md](references/waste-detection.md) |
+| Should I move storage to a cooler tier? | Storage tier analysis | [references/waste-detection.md](references/waste-detection.md) |
+| Am I paying for Windows/SQL licenses twice? | Azure Hybrid Benefit eligibility | [references/waste-detection.md](references/waste-detection.md) |
+| What's our tag coverage? | Tag inventory, then tag quality | [references/tags-and-policy.md](references/tags-and-policy.md) |
+| Are we governed? What guardrails are missing? | Policy inventory, then coverage gaps | [references/tags-and-policy.md](references/tags-and-policy.md) |
+| Should we buy reservations or savings plans? | Purchase recommendations | [references/commitments.md](references/commitments.md) |
+| Are we using what we already bought? | Commitment utilization | [references/commitments.md](references/commitments.md) |
+| What have commitments actually saved us? | Realized savings | [references/commitments.md](references/commitments.md) |
+| How is our MACC tracking? | Consumption commitment burn-down | `azure-cost-management` → `references/azure-macc.md` |
+| What are we spending? What's the forecast? | Cost summary and trend | [references/cost-analysis.md](references/cost-analysis.md) |
+| Which resources cost the most? | Resource cost ranking | [references/cost-analysis.md](references/cost-analysis.md) |
+| Split cost by team / app / cost center | Cost by tag | [references/cost-analysis.md](references/cost-analysis.md) |
+| What does one VM really cost? | VM cost including disks and IP | [references/additional-investigations.md](references/additional-investigations.md) |
+| What are we paying per vCPU or per GB? | Unit economics | [references/additional-investigations.md](references/additional-investigations.md) |
+| What are we spending on Azure OpenAI? | AI workload cost per 1K tokens | [references/additional-investigations.md](references/additional-investigations.md) |
+| Are we running retiring SKUs? | Legacy resources | [references/additional-investigations.md](references/additional-investigations.md) |
+| Was the budget ever realistic? | Budget history | [references/additional-investigations.md](references/additional-investigations.md) |
+| What does the tenant hierarchy look like? | Management group structure | [references/additional-investigations.md](references/additional-investigations.md) |
+| Split shared hub or platform cost | Shared and telemetry-keyed splitting | [references/allocation.md](references/allocation.md) |
+| Why did cost spike? | Anomaly root cause | `anomaly-investigation` skill |
+| Are we on budget? | Budget status | `forecasting-budgeting` skill |
+| Advisor cost recommendations | Advisor query | `azure-cost-management` → `references/azure-advisor.md` |
+| Design a showback or chargeback model | Allocation modelling | `cost-allocation` skill |
+| What's our carbon footprint? | Emissions and waste co-benefit | `sustainability-carbon` skill |
+
+When `azure-cost-management` already documents an API, use it rather than duplicating the call here. This skill adds the sequencing and interpretation on top.
+
+## Interpreting common results
+
+This is the part raw API output gets wrong. Apply these before reporting a number.
+
+- **Deallocated VMs are still costing money.** Compute stops billing when a VM is deallocated, but attached managed disks and static public IPs keep billing. A "stopped" VM is a finding, not a resolved item. Report the disk and IP cost, and recommend deleting if the VM is genuinely unused.
+- **Advisor repeats reservation recommendations.** Advisor commonly returns the same purchase 3-6 times across overlapping scopes. Summing them inflates the savings estimate several-fold. De-duplicate on subscription + resource type + term + SKU + region + quantity before totalling. See [references/commitments.md](references/commitments.md).
+- **Cost by tag returns nothing although resources are tagged.** Two usual causes: the tag isn't enabled as a **cost-allocation dimension** in Cost Management settings, or **month-to-date lag** means tag-dimensioned data hasn't populated. Confirm the tag is applied to resources first, then advise enabling tag-based cost allocation.
+- **Empty cost results early in the month.** Cost Management data lags by a day or more. Say so rather than reporting "$0".
+- **Azure Hybrid Benefit uses a different marker per resource type.** `Windows_Server` on Windows VMs, `AHUB` on SQL Server VMs, `BasePrice` on SQL Database and Managed Instance. Checking only the first will miss most of the estate and overstate the opportunity.
+- **Low tag coverage is usually a naming problem.** Before reporting a coverage percentage, check for casing and separator variants of the same tag (`managed_by` vs `managedBy` vs `ManagedBy`). They fragment coverage and each looks like a distinct tag.
+
+## Reading results
+
+- Lead with the headline number - count, total savings, coverage percentage.
+- Group findings by impact when the data supports it.
+- Translate inventory into action: "3 deallocated VMs, disks still billing, delete if unused" beats "3 deallocated VMs".
+- Surface the cost driver, not just the resource list.
+- Name the scope you scanned every time.
+
+## Remediation
+
+This skill does not change Azure. When a finding warrants action, hand the user to `Start-FinOpsMultitool`, which previews every change, requires explicit confirmation, and records an audit trail. Recommend the change and explain the impact; let the tool apply it.
+
+## FinOps skill ecosystem
+
+Once an investigation surfaces a finding, hand off to the skill that turns it into a decision, a design, or an artifact:
+
+| After finding | Hand off to | For |
+| ---------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------- |
+| Any spend question against a large environment | `cost-data-source` | Choose the FinOps hub Kusto path over the live API, chunk large tenants |
+| Tag coverage or cost-by-tag results | `cost-allocation` | Showback/chargeback model, shared-cost splitting, tag strategy |
+| Tag or policy gaps | `azure-policy-governance` | Enforce tags, regions, and SKUs via Azure Policy |
+| Policy inventory results | `azure-workbooks-finops` | Live in-portal governance and optimization workbooks |
+| Cost trend or resource cost data | `power-bi-finops` | Dashboards and visuals |
+| Realized savings or utilization | `unit-economics` | Effective savings rate, cost per unit, coverage KPIs |
+| Reservation or Hybrid Benefit opportunities | `rate-optimization-portfolio` | Portfolio mix and purchase planning |
+| Budget status | `forecasting-budgeting` | Forecasts, budget design, variance analysis |
+| A cost spike | `anomaly-investigation` | Root-cause down to the resource and change |
+| Waste findings | `sustainability-carbon` | Carbon co-benefit of removing waste |
+| Deep KQL against a FinOps hub | `finops-toolkit` | Kusto analytics on the hub database |
+| Single-instrument API mechanics | `azure-cost-management` | Reservations, savings plans, budgets, exports, MACC detail |
+| Cost data looks wrong or incomplete | `focus-data-quality` | FOCUS conformance, completeness, mapping |
+| A finding the user wants written up | `finops-reporting` | Executive summaries, QBRs, variance narratives |
+
+Gather the data first so the numbers are real, then route. Don't answer governance, allocation, or reporting questions abstractly when a query can show the actual state.
+
+## Prerequisites
+
+- Azure authentication: `az login`, or `Connect-AzAccount` when using the terminal UI.
+- **Reader** on the target scope for resource and policy investigations.
+- **Cost Management Reader** for cost, budget, and anomaly investigations.
+- **Billing Reader**, or Enterprise Administrator (reader) on an Enterprise Agreement, for billing account, contract, and MACC investigations.
+- **Carbon Optimization Reader** for emissions data.
+- The terminal UI additionally needs the `FinOpsToolkit` PowerShell module and the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules.
diff --git a/src/templates/agent-skills/finops-multitool/references/additional-investigations.md b/src/templates/agent-skills/finops-multitool/references/additional-investigations.md
new file mode 100644
index 000000000..9e33389b0
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/additional-investigations.md
@@ -0,0 +1,100 @@
+# Unit economics and remaining investigations
+
+Cost per unit of capacity, plus the investigations not covered by the other reference pages.
+
+## Unit economics
+
+Divide amortized cost by provisioned capacity. Four ratios, each answering a different question.
+
+| Metric | Formula | Answers |
+| ------ | ------- | ------- |
+| Cost per vCPU | compute cost ÷ total provisioned vCPUs | Are we paying a fair rate for compute? |
+| Cost per GB RAM | compute cost ÷ total provisioned memory GB | Is the workload memory-skewed? |
+| Cost per VM | compute cost ÷ VM count | Rough fleet average, useful for trend |
+| Cost per GB stored | storage cost ÷ total GB | Is storage tiered correctly? |
+
+### Getting the numbers right
+
+**Use amortized cost, not actual.** A reservation purchase lands as a lump on the purchase date in actual cost. Divide that by vCPUs and the month of purchase shows an absurd rate. Amortized spreads it across the term, which is what a unit rate should reflect.
+
+**vCPU and memory are not in Resource Graph.** The `resources` table exposes `vmSize` but not its capabilities. Resolve size → vCPU/memory through the Compute SKUs API per region:
+
+```bash
+az vm list-skus --location --resource-type virtualMachines \
+ --query "[].{name:name, caps:capabilities}" -o json
+```
+
+Cache it per region — the response is large and identical for every VM in that region. Falling back to parsing the size name (`Standard_D4s_v5` → 4 vCPU) works often enough to be dangerous: it silently misreads constrained-vCPU sizes and several families. Prefer the API and note when you had to guess.
+
+**Storage GB has two sources.** Provisioned managed-disk size from Resource Graph, plus *used* capacity for storage accounts from the `UsedCapacity` metric — one call per account. Disks are provisioned; blob storage is consumed. Mixing provisioned and used without saying so produces a rate nobody can reconcile.
+
+### Reading the result
+
+A unit rate is only meaningful as a trend or a comparison. "$47 per vCPU per month" alone means nothing. Rising month over month with flat capacity means rate erosion — usually an expiring commitment. Falling with flat cost means capacity was added without cost, which usually means something is provisioned and idle.
+
+Pair a rising cost-per-vCPU with commitment utilization before concluding anything. See `commitments.md`.
+
+## Legacy resources
+
+Retiring SKUs, deprecated API versions, and resources on classic deployment models. These carry migration risk and often a price premium.
+
+```kusto
+resources
+| where sku.name in ('Basic', 'Standard_LRS')
+ or type startswith 'microsoft.classic'
+| project name, type, resourceGroup, subscriptionId, sku = sku.name, location
+```
+
+Adjust the SKU list to what you're hunting. The high-value cases are Basic-tier public IPs and load balancers (both retiring), classic storage and compute, and unmanaged disks.
+
+Report the retirement date alongside the finding — "deprecated" without a date doesn't drive action.
+
+## Budget history
+
+Budget status shows the current period. History shows whether the budget was ever realistic.
+
+Pull budgets from `Microsoft.Consumption/budgets`, then query cost per month over the same window and compare. A budget exceeded every month for six months isn't an alerting problem, it's a budget that was set wrong — recommend re-baselining rather than more alerts.
+
+See `forecasting-budgeting` for budget design.
+
+## AI workload spend
+
+Azure OpenAI and Cognitive Services cost, joined to token telemetry.
+
+Cost comes from Cost Management filtered to `MICROSOFT.COGNITIVESERVICES`. Token volume comes from Azure Monitor:
+
+```kusto
+AzureMetrics
+| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES'
+| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens')
+| summarize tokens = sum(Total) by Resource, MetricName
+```
+
+The useful output is **cost per 1K tokens**, per deployment. That's the number that tells you whether a model choice is defensible — and it exposes the case where a small amount of traffic on an expensive model dominates spend.
+
+Prompt and generated tokens price differently. Keep them separate.
+
+## VM cost breakdown
+
+Full cost for one VM: compute, plus attached disks, plus the public IP, plus bandwidth.
+
+Cost Management grouped by `ResourceId` gives the compute line. The attached resources bill separately and are easy to miss — which is exactly why a deallocated VM still costs money.
+
+Resolve the VM's disks and NICs from Resource Graph, then sum their costs alongside. Reporting the compute line alone understates a VM's real cost, often substantially for storage-heavy machines.
+
+## Tenant hierarchy
+
+Management group and subscription structure, used to understand scope before a broad scan.
+
+```bash
+az account management-group list --query "[].{name:name, displayName:displayName}" -o table
+```
+
+Mostly a prerequisite rather than a finding. Useful when cost is being reported at the wrong scope, or when a subscription sits outside the expected hierarchy and is escaping policy.
+
+## Related
+
+- `unit-economics` skill for KPI definitions and business denominators
+- `commitments.md` for the commitment side of a rising unit rate
+- `cost-analysis.md` for amortized versus actual cost
+- `forecasting-budgeting` for budget design and variance
diff --git a/src/templates/agent-skills/finops-multitool/references/allocation.md b/src/templates/agent-skills/finops-multitool/references/allocation.md
new file mode 100644
index 000000000..9d31d1bc4
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/allocation.md
@@ -0,0 +1,129 @@
+# Cost allocation
+
+Splitting shared cost across the teams that consume it. Two distinct problems with different solutions.
+
+`cost-allocation` covers the showback/chargeback model design. This page covers the arithmetic.
+
+## Which problem are you solving?
+
+| Consumer is... | Native Azure allocation? | Method |
+| ---------------------------------------------------------- | ------------------------------- | ----------------------------------------------------- |
+| A subscription, resource group, or tag | **Yes** — cost allocation rules | Write a rule; cost moves in Cost Management |
+| A hub resource shared by spoke subscriptions | Partly | Split by a measured key, then optionally write a rule |
+| A Kubernetes namespace, APIM product, or OpenAI deployment | **No** | Telemetry-keyed showback only |
+
+That last row is the trap. Azure cost allocation rules can only key on **SubscriptionId, ResourceGroupName, or Tag**. A namespace is none of those. Any split you produce for it is a reporting artifact — it can never be written back into Cost Management, and presenting it as though it can is a promise you can't keep.
+
+## Shared hub cost
+
+Shared connectivity — ExpressRoute gateway and circuit, VPN gateway, Azure Firewall, shared bandwidth — bills entirely to the hub subscription. Cost Management cannot tell which spoke generated which gigabyte, because the split key lives in network telemetry rather than billing data.
+
+### The split model
+
+```text
+spoke_i = (Fixed / nSpokes) + (Variable × weight_i / totalWeight)
+
+Fixed = pool × FixedRatio split evenly
+Variable = pool × (1 − FixedRatio) split by measured weight
+```
+
+The fixed component exists because a gateway costs money at zero traffic. Splitting the whole pool by transfer volume charges a spoke that sent nothing exactly nothing, which is wrong — it still had the circuit available.
+
+A `FixedRatio` of 0.3 to 0.5 is a reasonable starting point for connectivity. State the ratio you used; it's a policy decision, not a fact.
+
+### Weighting providers, best to worst
+
+| Provider | Accuracy | Source |
+| ------------------ | -------- | ------------------------------------------------------ |
+| `inline` | Exact | Caller supplies a measured GB/TB map |
+| `trafficAnalytics` | Exact | Traffic Analytics / VNet flow logs in Log Analytics |
+| `resourceCount` | Proxy | Billable resource count per spoke, from Resource Graph |
+| `equal` | None | Even split |
+
+**Per-spoke ExpressRoute attribution is impossible from billing data alone.** It requires the flow-log key. Without flow logs you are estimating — label the output that way rather than presenting a proxy split as measured.
+
+### Reporting it
+
+Each spoke's full solution cost is its own resources **plus** its allocated share. Reporting only the allocated share understates what the team actually consumes, and reporting only their own resources hides the shared cost entirely — which is the problem you started with.
+
+## Telemetry-keyed showback
+
+For consumers with no billing dimension, pull a usage signal from Log Analytics and apportion the pool by it.
+
+### AKS namespace
+
+Source: Container Insights.
+
+```kusto
+InsightsMetrics
+| where TimeGenerated >= ago(30d)
+| where Name in ('cpuUsageNanoCores', 'memoryWorkingSetBytes')
+| extend ns = tostring(parse_json(Tags)['container.azm.ms/namespace'])
+| where isnotempty(ns)
+| summarize cpuCores = avgif(Val, Name == 'cpuUsageNanoCores') / 1000000000.0,
+ memGB = avgif(Val, Name == 'memoryWorkingSetBytes') / 1073741824.0
+ by ns
+| extend Weight = cpuCores + (memGB / 4.0)
+| project Consumer = ns, Weight
+```
+
+The `cpuCores + (memGB / 4)` blend approximates how AKS node SKUs are actually priced — roughly 4 GB of memory per vCPU. Weighting on CPU alone overcharges compute-light, memory-heavy workloads.
+
+### APIM by product or subscription
+
+Source: workspace-based Application Insights.
+
+```kusto
+AppMetrics
+| where TimeGenerated >= ago(30d)
+| where Name in ('Total Tokens', 'Tokens', 'TotalTokens', 'total_tokens')
+| extend consumer = tostring(Properties['Subscription Id'])
+| where isnotempty(consumer)
+| summarize Weight = sum(Sum) by Consumer = consumer
+```
+
+Swap the `Properties[...]` key for `API ID` or `Product` depending on which consumer you're charging. The metric name varies by APIM version, hence the `in (...)` list.
+
+### Azure OpenAI by deployment
+
+Source: Azure Monitor metrics.
+
+```kusto
+AzureMetrics
+| where TimeGenerated >= ago(30d)
+| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES'
+| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'ProcessedInferenceTokens')
+| summarize Weight = sum(Total) by Consumer = Resource
+```
+
+**Prompt and generated tokens are not priced the same.** Summing them into one weight is a simplification. If the split drives real chargeback, weight them separately at their respective rates.
+
+### Any custom consumer
+
+The pattern generalizes: a query returning two columns, `Consumer` (string) and `Weight` (number). Everything downstream is the same apportionment.
+
+## Applying the split
+
+Once weights exist:
+
+```text
+consumer_i = pool × (weight_i / totalWeight)
+```
+
+Then decide what happens to it:
+
+- **Subscription, resource group, or tag consumers** — writable as a native cost allocation rule. Percentages are normalized to sum to 100.
+- **Everything else** — showback only. Report it; don't imply Cost Management will reflect it.
+
+## Sanity checks before reporting
+
+- **Do the parts sum to the pool?** Rounding across many consumers drifts. Reconcile and put the remainder somewhere explicit.
+- **Is the telemetry window the same as the cost window?** 30 days of usage against a calendar month of cost is a mismatch that nobody notices until the numbers are questioned.
+- **Did every consumer emit telemetry?** A namespace with no metrics gets zero weight and therefore zero cost, which is almost never true. Flag missing consumers rather than silently charging them nothing.
+- **Is the pool the right pool?** Resolve the shared resources explicitly. Sweeping in a resource group can pick up non-shared resources and inflate every share.
+
+## Related
+
+- `cost-allocation` skill for the showback/chargeback model and tag strategy
+- `tags-and-policy.md` for the tag coverage that native allocation depends on
+- `azure-cost-management` → `references/azure-cost-exports.md` for the underlying cost data
diff --git a/src/templates/agent-skills/finops-multitool/references/commitments.md b/src/templates/agent-skills/finops-multitool/references/commitments.md
new file mode 100644
index 000000000..de2a00c31
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/commitments.md
@@ -0,0 +1,90 @@
+# Commitment discounts
+
+Reservations, savings plans, and the analysis around them: what to buy, whether existing commitments are being used, and what they've actually saved.
+
+`azure-cost-management` documents the underlying APIs in `references/azure-reservations.md`, `references/azure-savings-plans.md`, and `references/azure-commitment-discount-decision.md`. This page covers the sequencing and the places the raw data misleads.
+
+## Purchase recommendations
+
+Two sources, and they disagree by design:
+
+| Source | Endpoint | Scope |
+| ------------------------------ | -------------------------------------------------- | ------------------------------- |
+| Azure Advisor | `az advisor recommendation list --category Cost` | Subscription, filtered to cost |
+| Reservation Recommendation API | `Microsoft.Consumption/reservationRecommendations` | Billing account or subscription |
+
+Advisor is easier to reach and is what most agents land on first. It has a serious reporting trap.
+
+### Advisor emits duplicate recommendations
+
+Azure Advisor commonly returns **the same purchase 3-6 times**. The records differ only by recommendation GUID, produced by overlapping generation cycles. They are not distinct opportunities.
+
+Summing `annualSavingsAmount` across raw Advisor records inflates the estimate several-fold. On a large estate this turns a real $40k opportunity into a reported $200k, and nothing in the response signals that anything is wrong.
+
+De-duplicate on the meaningful tuple before totalling:
+
+```text
+subscriptionId | resourceType | term | SKU | region | quantity | annualSavings
+```
+
+Keep a count of how many records collapsed into each row. It's useful context, and a high count is itself a signal that Advisor is churning recommendations.
+
+Report the de-duplicated total. If you present a raw Advisor sum, say explicitly that it may contain duplicates.
+
+### Interpreting the recommendation
+
+- **Term.** Advisor usually recommends both 1-year and 3-year. They're alternatives, not additive. Never sum across terms.
+- **Lookback.** Recommendations are generated from a 7, 30, or 60-day window. A 7-day lookback after an atypical week produces a bad recommendation. Prefer 30 or 60 day where available.
+- **Scope.** Shared-scope reservations apply across subscriptions and generally utilize better than single-subscription ones. A recommendation scoped to one subscription may still be the wrong shape.
+- **Quantity.** Advisor recommends the quantity that maximizes savings at its assumed utilization. If the workload is shrinking, buy under the recommendation.
+
+## Commitment utilization
+
+The question behind this: _are we wasting what we already bought?_
+
+```bash
+az rest --method get --url "https://management.azure.com/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01"
+```
+
+Utilization comes from `Microsoft.Consumption/reservationSummaries`, aggregated daily or monthly.
+
+Thresholds worth flagging:
+
+| Utilization | Reading |
+| ----------- | ------------------------------------------------------------------------ |
+| Below 70% | Material waste. Investigate scope and workload changes. |
+| 70-90% | Normal for a fluctuating estate. Watch, don't act. |
+| Above 95% | Healthy, and possibly under-bought. Check for uncovered on-demand usage. |
+
+Low utilization has three usual causes, in order of frequency: the reservation is scoped too narrowly, the workload moved region or SKU family, or the workload shrank. Check scope first — it's the cheapest to fix, since scope can be changed without an exchange.
+
+## Realized savings
+
+What commitments have already delivered, versus on-demand rates. This is the number FinOps teams report upward, and it's distinct from _projected_ savings in a recommendation.
+
+Sources: cost data with `pricingModel` or `benefitId` populated, compared against retail rates from the Retail Prices API (`azure-cost-management` → `references/azure-retail-prices.md`).
+
+Report realized savings and projected savings separately and label them clearly. Blending "we saved $X" with "we could save $Y" is how a savings number loses credibility.
+
+## MACC
+
+Microsoft Azure Consumption Commitment burn-down is documented fully in `azure-cost-management` → `references/azure-macc.md`, including the critical detail that `closedBalance` is the **remaining** balance, not the consumed amount.
+
+Consumed is `originalAmount - closedBalance`. Reporting `closedBalance` as spend inverts the number.
+
+## Sequencing
+
+For a commitment review, run in this order:
+
+1. **Utilization first.** No point recommending purchases while existing commitments sit at 60%.
+2. **Coverage second.** How much on-demand usage is uncovered? That's the actual opportunity size.
+3. **Recommendations third.** De-duplicated, with term treated as a choice rather than a sum.
+4. **Realized savings last.** Establishes credibility for the recommendation that precedes it.
+
+Leading with purchase recommendations before checking utilization is the most common way a commitment conversation goes wrong.
+
+## Related
+
+- `rate-optimization-portfolio` for portfolio mix and purchase planning
+- `unit-economics` for effective savings rate and coverage KPIs
+- `azure-cost-management` → `references/azure-commitment-discount-decision.md` for reservations vs savings plans
diff --git a/src/templates/agent-skills/finops-multitool/references/cost-analysis.md b/src/templates/agent-skills/finops-multitool/references/cost-analysis.md
new file mode 100644
index 000000000..05a5666b1
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/cost-analysis.md
@@ -0,0 +1,94 @@
+# Cost analysis
+
+Spend, forecast, trend, and cost broken down by resource or tag — plus the data-source decision that determines whether any of it scales.
+
+`azure-cost-management` documents the Cost Management APIs themselves. This page covers choosing the data path and reading the results correctly.
+
+## Choose the data path first
+
+Three ways to get cost data, in descending order of scale:
+
+| Path | When | How |
+| ------------------- | -------------------------------------- | ----------------------------------------------- |
+| FinOps hub Kusto | A hub is deployed and covers the scope | Query the hub's ADX or Fabric database directly |
+| Cost Management API | No hub, or a small scope | `az rest` against the query API |
+| Hub storage exports | Fallback for small datasets only | Read Parquet/CSV from the hub storage account |
+
+**Push aggregation into Kusto when a hub exists.** A production hub can hold tens of GB and hundreds of millions of rows. Summarizing in the engine and returning only the result set is the difference between a working answer and a query that never completes.
+
+Discover a hub cluster:
+
+```kusto
+resources
+| where type =~ 'microsoft.kusto/clusters'
+| where tags['ftk-tool'] == 'FinOps hubs'
+| project name, resourceGroup, subscriptionId, uri = properties.uri
+```
+
+For local or offline analysis, the `ftklocal` Kusto emulator hosts the same hub database schema. See the `finops-toolkit` skill for the hub query catalog and table shapes.
+
+## Cost summary and forecast
+
+```bash
+az rest --method post \
+ --url "https://management.azure.com/subscriptions//providers/Microsoft.CostManagement/query?api-version=2023-11-01" \
+ --body '{
+ "type": "ActualCost",
+ "timeframe": "MonthToDate",
+ "dataset": { "granularity": "None", "aggregation": { "totalCost": { "name": "Cost", "function": "Sum" } } }
+ }'
+```
+
+Forecast uses the same shape against `.../forecast`.
+
+## Cost by tag
+
+Add a grouping to the dataset:
+
+```json
+"dataset": {
+ "granularity": "None",
+ "aggregation": { "totalCost": { "name": "Cost", "function": "Sum" } },
+ "grouping": [ { "type": "TagKey", "name": "" } ]
+}
+```
+
+**When this returns nothing but resources are clearly tagged**, there are two causes and they need different advice:
+
+1. **The tag isn't enabled as a cost-allocation dimension.** Cost Management only dimensions cost by tags that have been explicitly enabled in settings. This is the usual cause and it's a settings change, not a tagging problem.
+2. **Month-to-date lag.** Tag-dimensioned data populates behind raw cost. Early in a month it can be empty even when configured correctly.
+
+Confirm the tag is applied to resources first (see `tags-and-policy.md`), then check the setting. Reporting "no cost by that tag" without distinguishing these sends people to re-tag an estate that's already tagged.
+
+## Reading cost results
+
+- **Billed versus effective cost.** When `BilledCost` is zero — common for commitment-covered usage — fall back to `EffectiveCost`. Treating zero as zero spend undercounts anything covered by a reservation or savings plan. Hub queries and API queries must apply the same rule or the two paths disagree.
+- **Amortized versus actual.** Actual cost shows a reservation purchase as a lump on the purchase date. Amortized spreads it across the term. Use amortized for unit economics and trend; use actual for invoice reconciliation. Say which one you used.
+- **Currency.** Multi-billing-account estates can mix currencies. Never sum across them without converting, and state the currency in the headline number.
+- **Month-to-date is not a month.** Comparing an in-progress month against complete months in a trend produces a fake decline. Either exclude the current month or annotate it.
+- **Empty results early in the month.** Cost Management data lags by a day or more. Say so rather than reporting "$0".
+
+## Resource cost ranking
+
+Group by `ResourceId` and order descending. Two things to watch:
+
+- **Resource IDs are not resource names.** Present a readable name and resource group; keep the ID for drill-down.
+- **The long tail matters.** Top 10 by cost usually finds the obvious. Concentration — what share the top 10 represent — is often the more useful number. A flat distribution and a top-heavy one call for different strategies.
+
+## Trend
+
+Monthly granularity over 6-12 months, grouped as needed. When reporting a change, separate the causes:
+
+- Rate change — same usage, different price (commitment expired, region moved, SKU changed)
+- Usage change — genuinely more or less consumption
+- Scope change — subscriptions added or removed from the comparison
+
+Reporting "cost rose 40%" without identifying which of these is the driver isn't an answer. A subscription added to the scope isn't a cost increase at all.
+
+## Related
+
+- `cost-data-source` for the full hub-versus-API decision and chunking large tenants
+- `forecasting-budgeting` for forecast method and budget design
+- `anomaly-investigation` for root-causing a spike
+- `finops-toolkit` for the hub Kusto query catalog
+- `azure-cost-management` → `references/azure-cost-exports.md` for scheduled FOCUS exports
diff --git a/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md b/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md
new file mode 100644
index 000000000..82e541136
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md
@@ -0,0 +1,123 @@
+# Tags and policy
+
+Tag hygiene and Azure Policy coverage — the governance foundation that cost allocation depends on.
+
+Queries run read-only against Azure Resource Graph via `az graph query`, an Azure MCP Resource Graph tool, or `Search-AzGraph`.
+
+## Tag inventory
+
+What tags exist, on how many resources, with what values:
+
+```kusto
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| extend tagValue = tostring(tags[tagName])
+| where isnotempty(tagName)
+| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue
+| order by tagName asc, ResourceCount desc
+```
+
+The `union resourcecontainers` matters — it includes subscriptions and resource groups, which carry the tags that inherited allocation models depend on. Omitting it undercounts coverage badly.
+
+What carries no tags at all:
+
+```kusto
+resources
+| where isnull(tags) or tags == '{}'
+| project name, type, resourceGroup, subscriptionId, location
+| order by type asc, name asc
+```
+
+Where a given tag is applied, by scope:
+
+```kusto
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| where isnotempty(tagName)
+| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup
+| order by tagName asc, ResourceCount desc
+```
+
+## Reading tag coverage
+
+**Check for naming variants before reporting a coverage percentage.** The most common finding in a real estate isn't missing tags — it's the same tag spelled several ways:
+
+```text
+managed_by ManagedBy managedBy Managed-By
+costcenter CostCenter cost_center CC
+```
+
+Each variant appears as a distinct tag in the inventory. Coverage looks like 30% across four tags when it's really 85% of one tag with inconsistent naming. Group case-insensitively and normalize separators before computing coverage, then report the variants as the finding.
+
+Other things worth flagging:
+
+- **Tag values that should be an enum but aren't.** Twelve spellings of one team name breaks any groupby.
+- **Tags on resources but not on resource groups.** Inherited allocation models silently fail.
+- **Reserved prefixes.** Tags beginning `hidden-` or `microsoft` are platform-managed; exclude them from coverage math.
+
+A tag is only useful for cost allocation if it's also enabled as a **cost-allocation dimension** in Cost Management settings. High tag coverage with no cost-by-tag data almost always means that setting was never turned on. See `cost-analysis.md`.
+
+## Policy inventory
+
+Assignments in scope:
+
+```kusto
+policyresources
+| where type =~ 'microsoft.authorization/policyassignments'
+| project id, name, properties, subscriptionId, type
+```
+
+Compliance rollup:
+
+```kusto
+policyresources
+| where type =~ 'microsoft.policyinsights/policystates'
+| extend complianceState = tostring(properties.complianceState)
+| summarize
+ Compliant = countif(complianceState =~ 'Compliant'),
+ NonCompliant = countif(complianceState =~ 'NonCompliant'),
+ Total = count()
+ by subscriptionId
+```
+
+**A subscription with no policy state records is not compliant — it's unevaluated.** Report those separately. Rolling "no data" into a compliance percentage is the fastest way to produce a governance report that's wrong in the reassuring direction.
+
+## Policy coverage gaps
+
+The useful output isn't the list of assignments — it's what's missing. Compare assigned policies against the guardrails a cost-governed environment normally has:
+
+| Guardrail | Typical built-in |
+| ----------------------------------------- | -------------------------------------------------- |
+| Require a tag on resources | `Require a tag on resources` |
+| Inherit a tag from the resource group | `Inherit a tag from the resource group if missing` |
+| Restrict deployment regions | `Allowed locations` |
+| Restrict VM SKUs | `Allowed virtual machine size SKUs` |
+| Audit resources without a cost center tag | Custom, usually |
+
+Tag inheritance is the highest-leverage one and the most often missing. `Inherit a tag from the resource group if missing` (definition ID `ea3f2387-9b95-492a-a190-fcdc54f7b070`) is a `modify` effect — it fixes allocation gaps going forward without touching existing resources, and it needs a managed identity on the assignment.
+
+When recommending policy, distinguish the effects:
+
+- **Audit** — reports, changes nothing. Safe to assign broadly, and the right first step.
+- **Modify** — fixes resources as they're created or remediated. Needs a managed identity.
+- **Deny** — blocks deployments. Real breakage risk; assign only after an audit period shows the impact.
+
+Recommending `deny` on a live subscription without an audit period first is how a governance rollout gets rolled back.
+
+## Sequencing
+
+1. Tag inventory first — you can't recommend tag policy without knowing what's there.
+2. Normalize variants and report real coverage.
+3. Policy inventory, separating unevaluated from non-compliant.
+4. Gap analysis against the guardrail list.
+5. Recommend `audit` effects first, `modify` for inheritance, `deny` only with evidence.
+
+## Related
+
+- `azure-policy-governance` for authoring the policy definitions and assignments as Bicep or ARM
+- `cost-allocation` for turning tag coverage into a showback or chargeback model
+- `cost-analysis.md` for why tagged resources may still not appear in cost data
diff --git a/src/templates/agent-skills/finops-multitool/references/waste-detection.md b/src/templates/agent-skills/finops-multitool/references/waste-detection.md
new file mode 100644
index 000000000..33ef1be37
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/references/waste-detection.md
@@ -0,0 +1,208 @@
+# Waste detection
+
+Finding resources that cost money without delivering value: orphaned infrastructure, idle compute, mis-tiered storage, and unclaimed licensing benefits.
+
+All queries here are read-only. Run them with `az graph query -q ""`, an Azure MCP Resource Graph tool, or `Search-AzGraph`.
+
+## Orphaned resources
+
+Six distinct categories. Run them together and group the results — a single "orphans" number is more actionable than six separate ones.
+
+### Unattached managed disks
+
+```kusto
+resources
+| where type =~ 'microsoft.compute/disks'
+| where managedBy == '' or isnull(managedBy)
+| where properties.diskState == 'Unattached'
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ sku = sku.name, diskState = properties.diskState
+```
+
+Premium SSD disks are the expensive case. Report size and SKU, not just the count.
+
+### Unattached public IPs
+
+```kusto
+resources
+| where type =~ 'microsoft.network/publicipaddresses'
+| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration)
+| where properties.natGateway == '' or isnull(properties.natGateway)
+| project name, resourceGroup, subscriptionId, location,
+ sku = sku.name, ipAddress = properties.ipAddress,
+ allocationMethod = properties.publicIPAllocationMethod
+```
+
+The `natGateway` check matters. An IP attached to a NAT gateway has no `ipConfiguration` but is very much in use — omitting that filter produces false positives.
+
+Standard static IPs bill even when idle; Basic dynamic ones largely don't. Lead with the Standard ones.
+
+### Unattached network interfaces
+
+```kusto
+resources
+| where type =~ 'microsoft.network/networkinterfaces'
+| where isnull(properties.virtualMachine) or properties.virtualMachine == ''
+| where isnull(properties.privateEndpoint) or properties.privateEndpoint == ''
+| project name, resourceGroup, subscriptionId, location,
+ enableAcceleratedNetworking = properties.enableAcceleratedNetworking
+```
+
+NICs are free. They matter as a signal of abandoned deployments, not as a cost line — say so rather than implying savings.
+
+### Deallocated VMs
+
+```kusto
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated'
+ or properties.extended.instanceView.powerState.code == 'PowerState/deallocated'
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ powerState = properties.extended.instanceView.powerState.displayStatus
+```
+
+**The one people get wrong.** A deallocated VM stops billing for compute, so it looks resolved. Its managed disks and any static public IP keep billing indefinitely. Treat a long-deallocated VM as an open finding and quantify the disk cost.
+
+### Empty App Service plans
+
+```kusto
+resources
+| where type =~ 'microsoft.web/serverfarms'
+| where properties.numberOfSites == 0
+| where sku.tier != 'Free' and sku.tier != 'Shared'
+| project name, resourceGroup, subscriptionId, location,
+ sku = strcat(sku.tier, ' / ', sku.name),
+ workers = properties.numberOfWorkers
+```
+
+A plan with no sites bills the full tier. Multiply by `numberOfWorkers` — scaled-out empty plans are expensive.
+
+### Old snapshots
+
+```kusto
+resources
+| where type =~ 'microsoft.compute/snapshots'
+| where properties.timeCreated < datetime('')
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ timeCreated = properties.timeCreated
+```
+
+Substitute a cutoff date — 90 days back is a reasonable default. Confirm retention policy before recommending deletion; snapshots are sometimes the backup.
+
+## Idle virtual machines
+
+Two steps. Resource Graph gives the inventory:
+
+```kusto
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| extend powerState = tostring(properties.extended.instanceView.powerState.code)
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ osType = properties.storageProfile.osDisk.osType,
+ powerState
+```
+
+Then query Monitor metrics per running VM over a 14-day window:
+
+```bash
+az monitor metrics list --resource \
+ --metric "Percentage CPU" "Network In Total" "Network Out Total" \
+ --start-time <14d-ago> --end-time \
+ --aggregation Average Total --interval P14D
+```
+
+Classification used by the terminal UI:
+
+| Verdict | Criteria |
+| ------- | -------- |
+| Idle | average CPU < 5% **and** total network < 14 MB over 14 days |
+| Underutilized | average CPU < 10% **and** total network < 140 MB over 14 days |
+
+**Use both signals.** CPU alone misclassifies a busy file server or a network appliance as idle. A VM moving traffic is doing work regardless of processor load.
+
+Recommend deallocation for idle VMs and rightsizing for underutilized ones — they're different actions.
+
+## Storage tier optimization
+
+Find Hot-tier accounts:
+
+```kusto
+resources
+| where type =~ 'microsoft.storage/storageaccounts'
+| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier)
+| project name, resourceGroup, subscriptionId, location,
+ kind, sku = sku.name,
+ accessTier = tostring(properties.accessTier),
+ creationTime = properties.creationTime
+```
+
+Then check 30-day transaction volume and blob capacity per account:
+
+```bash
+az monitor metrics list --resource /blobServices/default \
+ --metric Transactions --start-time <30d-ago> --end-time \
+ --aggregation Total --interval P30D
+```
+
+Low transactions plus meaningful capacity means the tier is wrong. Recommend Cool for infrequent access, Archive for effectively dormant data.
+
+**Model the retrieval cost before recommending Archive.** Archive is cheap to store and expensive to read, with rehydration latency measured in hours. If the data is read even occasionally, Cool usually wins on total cost.
+
+## Azure Hybrid Benefit
+
+**Three resource types, three different license markers.** Checking only Windows VMs is the common mistake and understates the estate badly.
+
+### Windows VMs
+
+```kusto
+resources
+| where type == 'microsoft.compute/virtualmachines'
+| where properties.storageProfile.osDisk.osType =~ 'Windows'
+| where isempty(properties.licenseType) or (properties.licenseType !~ 'Windows_Server' and properties.licenseType !~ 'Windows_Client')
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ currentLicense = coalesce(tostring(properties.licenseType), 'None')
+```
+
+### SQL Server on VMs
+
+```kusto
+resources
+| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines'
+| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'),
+ sqlEdition = tostring(properties.sqlImageSku)
+```
+
+### SQL Database and Managed Instance
+
+```kusto
+resources
+| where type == 'microsoft.sql/servers/databases'
+| where sku.tier != 'Free' and name != 'master'
+| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'),
+ sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name))
+```
+
+Marker summary:
+
+| Resource type | Property | Value meaning AHB is on |
+| ------------- | -------- | ----------------------- |
+| Windows VM | `licenseType` | `Windows_Server` (or `Windows_Client`) |
+| SQL Server VM | `sqlServerLicenseType` | `AHUB` |
+| SQL Database / MI | `licenseType` | `BasePrice` |
+
+**Eligibility is a licensing question, not a technical one.** These queries find resources that *could* use the benefit. Whether the customer owns qualifying licenses with Software Assurance is something only they can confirm. Present the findings as an opportunity to verify, never as guaranteed savings.
+
+## Related
+
+- `azure-cost-management` → `references/azure-orphaned-resources.md` for additional orphan query patterns
+- `azure-cost-management` → `references/azure-vm-rightsizing.md` for SKU downsizing analysis
+- `sustainability-carbon` for the emissions co-benefit of removing waste
diff --git a/src/templates/agent-skills/finops-reporting/SKILL.md b/src/templates/agent-skills/finops-reporting/SKILL.md
new file mode 100644
index 000000000..add7887be
--- /dev/null
+++ b/src/templates/agent-skills/finops-reporting/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: finops-reporting
+description: Use when the user wants to turn cost data, scan output, or KQL results into an audience-ready narrative — executive summaries, monthly cost reviews, QBR decks, variance write-ups, or savings/optimization status reports. Converts numbers into decisions and recommendations.
+license: MIT
+compatibility: Works on output from the finops-multitool skill, the finops-toolkit KQL skill, or any cost dataset. Pairs with power-bi-finops for visuals and content-humanizer for tone.
+metadata:
+ author: microsoft
+ version: "1.0"
+---
+
+# FinOps reporting
+
+Translate cost analytics into the report the audience actually needs. The other skills produce numbers; this one produces narrative, structure, and recommendations.
+
+## When to use this skill
+
+Use it when the user asks for a summary, executive report, monthly/quarterly review, QBR, variance explanation, or "write up" of cost or savings. Gather the data first (`finops-multitool` scans, `finops-toolkit` KQL, or a full assessment for a broad sweep), then shape it here.
+
+## Match the report to the audience
+
+| Audience | Report | Focus | Length |
+|----------|--------|-------|--------|
+| Executive / finance | Executive summary | Total spend, trend, top movers, savings captured, 3 actions | 1 page |
+| Engineering leads | Optimization review | Waste, rightsizing, idle resources, owners, effort vs savings | 2–3 pages |
+| FinOps practice | Monthly cost review | Full breakdown, allocation, coverage, anomalies, forecast | Deck / workbook |
+| Account / QBR | Business review | Spend vs budget, ESR trend, commitments, roadmap | Deck |
+
+## Executive summary structure
+
+1. **Headline** — total spend, MoM/QoQ change %, one sentence on why.
+2. **Trend** — are we accelerating, flat, or declining? (cost trend, `monthly-cost-trend.kql`)
+3. **Top movers** — the 3 services/resource groups driving the change.
+4. **Savings captured** — ESR and realized savings (savings realized, `savings-summary-report.kql`).
+5. **Opportunities** — top 3 unrealized savings, each with $ impact, effort, and owner.
+6. **Anomalies / risks** — anything unusual, expiring commitments, budget overruns.
+7. **Recommended actions** — numbered, owned, with a target date. This is the part executives read.
+
+## Turning a scan into a recommendation
+
+For every finding, give: **what** (the issue), **how much** ($ / month or %), **effort** (low/med/high), **risk** (low/med/high), **owner**, **action**. A finding without a dollar figure and an owner is noise — quantify it from the scan data or say it's an estimate.
+
+## Writing rules
+
+- Lead with the number and the decision, not the methodology.
+- One idea per bullet; no walls of text in exec material.
+- Always express savings as $/month *and* annualized — annualized numbers move executives.
+- State assumptions explicitly (window, scope, billed vs effective cost) so the report survives scrutiny.
+- For the prose itself, apply the `content-humanizer` skill so it reads like a person, not a generator.
+
+## Hand-offs
+
+- Need the data → `finops-multitool` (a full assessment for breadth) or `finops-toolkit` KQL.
+- Need charts in the report → `power-bi-finops`.
+- Need efficiency KPIs (ESR, unit cost) → `unit-economics`.
+- Need budget vs actual variance → `forecasting-budgeting`.
diff --git a/src/templates/agent-skills/focus-data-quality/SKILL.md b/src/templates/agent-skills/focus-data-quality/SKILL.md
new file mode 100644
index 000000000..ffef47fc3
--- /dev/null
+++ b/src/templates/agent-skills/focus-data-quality/SKILL.md
@@ -0,0 +1,58 @@
+---
+name: focus-data-quality
+description: Use when the user works with FOCUS cost data — validating FOCUS conformance, mapping native Azure cost exports to FOCUS columns, checking dataset completeness/freshness, or troubleshooting ingestion gaps that make cost analysis wrong. FOCUS is the FinOps Open Cost and Usage Specification.
+license: MIT
+compatibility: Requires access to the cost dataset — FOCUS exports in storage, or a FinOps hub that ingests them. Pairs with the finops-toolkit skill (hub ingestion) and the finops-multitool skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+---
+
+# FOCUS data quality
+
+Good FinOps analysis depends on good data. FOCUS (FinOps Open Cost and Usage Specification) is the open standard that normalizes cost and usage across providers. This skill validates that the FOCUS data feeding everything else is complete, conformant, and trustworthy.
+
+## When to use this skill
+
+Use it when the user mentions FOCUS, cost exports, column mapping, ingestion gaps, "the numbers look wrong/incomplete," or data freshness. If a downstream skill (Power BI, reporting, allocation) produces suspicious results, suspect data quality and come here first.
+
+## What FOCUS gives you
+
+A common schema so the same query works across providers and over time. Key columns the toolkit and queries rely on:
+
+| Column | Meaning |
+|--------|---------|
+| `BilledCost` | What the invoice charges for the period |
+| `EffectiveCost` | Amortized cost (commitments spread over their term) |
+| `ListCost` | Cost at public list price (the savings baseline) |
+| `ContractedCost` | Cost at negotiated/contracted price |
+| `ChargeCategory` | Usage / Purchase / Tax / Credit / Adjustment |
+| `ServiceName`, `ResourceName`, `Region`, `Tags` | Dimensions for grouping/allocation |
+
+Reference: https://learn.microsoft.com/cloud-computing/finops/focus/what-is-focus
+
+## Conformance checks
+
+1. **Schema** — required FOCUS columns present and correctly typed; `x_` columns are toolkit enrichments, not FOCUS itself.
+2. **Cost relationships** — sanity-check `ListCost ≥ ContractedCost ≥ EffectiveCost` in aggregate; large violations signal a mapping error.
+3. **Charge categories** — Usage dominates; Purchase rows appear when commitments are bought; Credits net negative. Missing categories = partial export.
+
+## Completeness and freshness
+
+| Check | Bad sign | Cause |
+|-------|----------|-------|
+| **Date coverage** | Gaps or missing recent days | Export schedule broken or lagging (Azure cost data lags ~1–3 days) |
+| **Scope coverage** | A subscription/account missing | Export scope too narrow, or RBAC on the export |
+| **Row counts vs prior period** | Sudden drop | Failed export run or partial ingestion |
+| **Total vs portal** | Material mismatch | Billed-vs-effective confusion, missing scope, or currency mix |
+| **Tag dimension empty** | Tags blank in cost data | Tag not enabled as a cost-allocation dimension (common — see `cost-allocation`) |
+
+## Mapping native exports to FOCUS
+
+When data comes from a legacy/native Azure cost export rather than a FOCUS export, map before analyzing: cost-in-billing-currency → `BilledCost`, amortized cost → `EffectiveCost`, list price × quantity → `ListCost`, meter/service → `ServiceName`, resource id → `ResourceName`. The FinOps hub does this automatically on ingestion; defer to the `finops-toolkit` skill's `focus/mapping.md` for the full crosswalk.
+
+## Hand-offs
+
+- Ingestion pipeline health → `finops-toolkit` skill (hub ingestion / data-ingestion report).
+- Tag dimension empty → `cost-allocation` skill.
+- Once data is trusted → `power-bi-finops`, `finops-reporting`, `unit-economics`.
diff --git a/src/templates/agent-skills/forecasting-budgeting/SKILL.md b/src/templates/agent-skills/forecasting-budgeting/SKILL.md
new file mode 100644
index 000000000..1e3c81f13
--- /dev/null
+++ b/src/templates/agent-skills/forecasting-budgeting/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: forecasting-budgeting
+description: Use when the user wants to forecast future Azure cost, design or evaluate budgets, run budget-vs-actual variance analysis, set up budget alerts, or model the cost impact of a planned change. Covers planning and the "manage anomalies and budgets" side of FinOps.
+license: MIT
+compatibility: Requires cost history (Cost Management or a FinOps hub) for forecasting and Cost Management Contributor to create budgets/alerts. Pairs with the finops-toolkit KQL skill and the finops-multitool skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+allowed-tools: az pwsh
+---
+
+# Forecasting and budgeting
+
+Look forward, not just back. This skill projects future spend, designs budgets, and explains variance so teams can plan and stay accountable.
+
+## When to use this skill
+
+Use it when the user asks to forecast, budget, plan spend, explain why they're over/under budget, or set up budget alerts. Pull history from cost trend / `monthly-cost-trend.kql` and current budget state from budget status, then plan here.
+
+## Forecasting
+
+| Method | Use when | Source |
+|--------|----------|--------|
+| **Trend / run-rate** | Stable spend, short horizon | MTD ÷ days elapsed × days in month |
+| **Linear / seasonal model** | Months of history, want a defensible projection | `cost-forecasting-model.kql` |
+| **Driver-based** | A known change is coming (migration, new workload, ramp-down) | Build from the change, not history |
+
+Rules:
+- Forecast on `EffectiveCost` (amortized) for planning; use `BilledCost` only for invoice/cash projections.
+- Always state the horizon and confidence — a forecast without a range invites false precision.
+- Early in the month, run-rate forecasts are noisy (tag-dimensioned and some usage data lag a few days). Flag this rather than over-trusting a day-3 projection.
+- Re-forecast when a commitment expires or a major workload changes — both break historical trend.
+
+## Budget design
+
+1. **Scope** — subscription, resource group, or tag (CostCenter/App). Tag-scoped budgets need the allocation model from the `cost-allocation` skill.
+2. **Amount** — base on forecast + a planned-growth buffer, not last month flat.
+3. **Time grain** — monthly for ops, quarterly/annual for finance.
+4. **Alert thresholds** — set multiple (e.g. 50/80/100% actual, plus a forecasted-to-exceed alert). Forecasted alerts warn *before* the overrun.
+5. **Actions** — wire alerts to an action group (email/Teams/webhook). For automated response, trigger off the budget alert, not a manual check.
+
+## Variance analysis (budget vs actual)
+
+For an over/under, decompose the gap:
+- **Volume** — more/less usage of the same things.
+- **Rate** — price changed, or a discount/commitment expired (check ESR via `unit-economics`).
+- **New/retired** — resources added or removed since the budget was set.
+- **Allocation** — a tag changed and moved cost between buckets.
+
+Report each driver with its $ contribution so the variance is explained, not just stated.
+
+## Hand-offs
+
+- History + current budgets → `finops-multitool` (cost trend, budget status) / `finops-toolkit` KQL.
+- Rate-driven variance → `rate-optimization-portfolio` and `unit-economics`.
+- Sudden unexpected spike → `anomaly-investigation` skill.
+- Write up the variance → `finops-reporting`.
diff --git a/src/templates/agent-skills/power-bi-finops/SKILL.md b/src/templates/agent-skills/power-bi-finops/SKILL.md
new file mode 100644
index 000000000..97188e914
--- /dev/null
+++ b/src/templates/agent-skills/power-bi-finops/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: power-bi-finops
+description: Use when the user wants to connect, customize, troubleshoot, or build Power BI reports on Azure cost data, including the FinOps toolkit Power BI reports, the Cost Management connector, FinOps hubs (Azure Data Explorer / Microsoft Fabric) data sources, report refresh failures, or turning scan and KQL output into dashboards and visuals.
+license: MIT
+compatibility: Requires Power BI Desktop (or the Power BI service) and read access to the cost data source — a Cost Management scope, a FinOps hub ADX cluster, or a Fabric workspace. Pairs with the finops-multitool skill and the finops-toolkit skill.
+metadata:
+ author: microsoft
+ version: '1.0'
+allowed-tools: az pwsh
+---
+
+# Power BI for FinOps
+
+Build and operate Power BI reporting on Azure cost data. The FinOps toolkit ships pre-built Power BI report templates; this skill covers connecting them to a data source, customizing visuals, fixing refresh issues, and building new dashboards from cost data.
+
+## When to use this skill
+
+Use it when the user wants a visual or dashboard, mentions Power BI, `.pbix`/`.pbit` files, report refresh errors, the Cost Management connector, or asks to "visualize" cost, savings, or tag data. For headless analysis (numbers without visuals) prefer the `finops-multitool` skill or the `finops-toolkit` KQL skill, then bring the output here when the user wants it visualized.
+
+## Choosing a data source
+
+| Data source | Use when | Connector |
+| -------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **FinOps hubs (ADX/Kusto)** | A hub is deployed; want fast, large-scale, multi-month analytics | Azure Data Explorer connector → `Hub` database, `Costs()` / `Prices()` / `Recommendations()` / `Transactions()` |
+| **FinOps hubs (Microsoft Fabric RTI)** | Hub deployed to Fabric | Fabric / KQL database connector |
+| **Cost Management connector** | No hub; small scope; quick start | Power BI "Microsoft Cost Management" connector (billing account or EA/MCA scope) |
+| **FOCUS exports in storage** | Raw FOCUS cost exports only | Azure Data Lake Storage Gen2 connector → parse parquet/csv |
+
+The hub data sources are strongly preferred for anything beyond a single small subscription — the Cost Management connector is rate-limited and slow at scale.
+
+## Pre-built toolkit reports
+
+The toolkit publishes report templates aligned to FinOps capabilities. Match the user's intent to the report:
+
+| Report | Answers |
+| -------------------------- | ---------------------------------------------------------------- |
+| **Cost summary** | Where is spend going? Trends, top services/resources, MoM change |
+| **Rate optimization** | Are we paying the best rate? Commitment coverage and utilization |
+| **Commitment discounts** | Reservation / savings plan ROI, expirations, recommendations |
+| **Workload optimization** | Rightsizing, idle/underused resources, waste |
+| **Governance** | Tag coverage, policy compliance, allocation readiness |
+| **Data ingestion / FOCUS** | Pipeline health, dataset completeness |
+
+Setup steps: open the `.pbit` template → supply the data-source parameters (cluster URI + `Hub` database, or billing scope) → load. For first-time connection details defer to the `finops-toolkit` skill reference `toolkit/power-bi/setup.md` and `toolkit/power-bi/connector.md`.
+
+## Common refresh and connection failures
+
+| Symptom | Likely cause | Fix |
+| --------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| "We couldn't authenticate" on ADX | Stale credential / wrong tenant | Re-sign in; confirm the signed-in identity has Database Viewer on the hub cluster |
+| Empty visuals, no error | Querying `Ingestion` instead of `Hub`, or date filter outside loaded data | Point queries at the `Hub` database; widen the date slicer |
+| Refresh times out at scale | Cost Management connector on a large scope | Move to a FinOps hub data source; the connector does not scale |
+| "Parameter is required" on open | `.pbit` opened without supplying parameters | Re-open the template and fill cluster URI / database / scope |
+| Costs look low vs. portal | Comparing `BilledCost` to `EffectiveCost` (or vice versa) | Decide the right measure: `BilledCost` = invoice, `EffectiveCost` = amortized |
+
+## Building custom visuals
+
+1. Get the data shaped first — use a catalog `.kql` query from the `finops-toolkit` skill (e.g. `monthly-cost-trend.kql`, `cost-by-financial-hierarchy.kql`) as the report's query so the model is correct from the start.
+2. Use FOCUS column names as field labels (`BilledCost`, `EffectiveCost`, `ServiceName`, `ResourceName`, `Tags`) so reports stay portable across tenants.
+3. Default measures: `EffectiveCost` for trend/forecast, `BilledCost` for invoice reconciliation, `x_EffectiveCostSavings` for savings.
+4. For chargeback/showback pages, drive the hierarchy from the allocation model in the `cost-allocation` skill (tags + billing hierarchy) rather than ad-hoc grouping.
+
+## Hand-offs
+
+- Need the underlying numbers or a KQL query → `finops-toolkit` skill.
+- Need live read-only numbers to seed a visual → `finops-multitool` skill (cost trend, resource costs, tag inventory).
+- Need an executive narrative around the visuals → `finops-reporting` skill.
diff --git a/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md b/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md
new file mode 100644
index 000000000..d1d7c2129
--- /dev/null
+++ b/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: rate-optimization-portfolio
+description: Use when the user manages commitment discounts as a portfolio over time — deciding the right mix of reservations, savings plans, and Azure Hybrid Benefit, planning purchases against coverage gaps, tracking utilization and expirations, and maximizing Effective Savings Rate across the whole estate rather than one instrument at a time.
+license: MIT
+compatibility: Requires Cost Management read access (or a FinOps hub) for usage, recommendations, and commitment data. Purchase actions need Billing/Reservation permissions. Pairs with the finops-multitool skill and the azure-cost-management skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+---
+
+# Rate optimization portfolio
+
+Getting the best *rate* is a portfolio problem, not a one-off purchase. This skill manages the mix of reservations, savings plans, and Azure Hybrid Benefit over time to maximize Effective Savings Rate (ESR) without over-committing.
+
+## When to use this skill
+
+Use it when the user asks about reservations vs savings plans, commitment strategy, coverage gaps, utilization, expirations, or "are we paying the best rate." For a single instrument's mechanics defer to the `azure-cost-management` skill (azure-reservations, azure-savings-plans, azure-commitment-discount-decision); use *this* skill for the portfolio-level view across all of them.
+
+## The instruments
+
+| Instrument | Discount | Flexibility | Best for |
+|------------|----------|-------------|----------|
+| **Reservation (RI)** | Highest | Locked to a service/family/region (some exchange) | Stable, predictable workloads on a specific SKU |
+| **Savings plan** | Lower than RI | Flexible across compute services/regions | Variable compute you'll keep running but may reshape |
+| **Azure Hybrid Benefit (AHB)** | Removes Windows/SQL license cost | Reassignable | Existing Windows Server / SQL with Software Assurance |
+| **On-demand** | None | Total | Spiky, short-lived, or uncertain workloads |
+
+Layer them: AHB first (license), then RIs for the stable base, then a savings plan over the flexible remainder, leaving truly variable load on-demand.
+
+## Portfolio workflow
+
+1. **Baseline coverage** — what % of commitment-eligible cost is already covered? (commitment utilization, `commitment-discount-utilization.kql`)
+2. **Utilization** — are existing commitments fully used? Under-utilization is waste *worse than* on-demand. Fix before buying more.
+3. **Gap** — the stable, uncovered base is the buy target. Size to baseline usage, not peak — you can always add, you can't easily unwind.
+4. **Instrument choice** — RI for steady single-SKU base; savings plan for flexible compute; AHB for eligible Windows/SQL (ahb opportunities).
+5. **Term** — 1-year for changing estates, 3-year for proven-stable workloads (higher discount, longer lock).
+6. **Track expirations** — model the ESR cliff when a commitment lapses; renew or re-shape ahead of expiry.
+
+## Key signals
+
+| Signal | Meaning | Action |
+|--------|---------|--------|
+| Coverage low, utilization high | Under-committed | Buy into the stable base |
+| Utilization low | Over-committed / wrong SKU | Exchange, right-size, or let lapse — don't buy more |
+| ESR falling with no usage change | A commitment expired | Renew/re-shape (`anomaly-investigation`) |
+| AHB-eligible VMs at full rate | Leaving license savings on the table | Apply AHB (ahb opportunities) |
+| Recommendations show large net savings | Genuine gap | Validate against baseline, then commit |
+
+## Guardrails
+
+- Never size a commitment to peak — size to the floor of sustained usage.
+- Prefer flexibility (savings plan, 1-year) when the estate is changing; take the deeper discount (RI, 3-year) only when stability is proven.
+- Treat utilization as the first KPI — an unused reservation costs more than paying on-demand.
+
+## Hand-offs
+
+- Single-instrument mechanics / decision criteria → `azure-cost-management` skill.
+- Coverage/utilization/AHB data → `finops-multitool` scans.
+- Express the result as ESR / coverage KPIs → `unit-economics`.
+- Recommendation breakdown from hub data → `finops-toolkit` (`reservation-recommendation-breakdown.kql`).
diff --git a/src/templates/agent-skills/sustainability-carbon/SKILL.md b/src/templates/agent-skills/sustainability-carbon/SKILL.md
new file mode 100644
index 000000000..928943928
--- /dev/null
+++ b/src/templates/agent-skills/sustainability-carbon/SKILL.md
@@ -0,0 +1,58 @@
+---
+name: sustainability-carbon
+description: Use when the user wants to measure or reduce the carbon emissions of their Azure footprint — the Emissions Impact Dashboard, carbon optimization recommendations, or aligning cost optimization with sustainability goals. Treats carbon as a FinOps-adjacent efficiency dimension alongside cost.
+license: MIT
+compatibility: Requires access to the Microsoft Emissions Impact Dashboard / Azure carbon optimization (Reader on the relevant scope). Pairs with the finops-multitool skill for the cost side of the same resources.
+metadata:
+ author: microsoft
+ version: '1.0'
+---
+
+# Sustainability and carbon
+
+Cost and carbon are two views of the same efficiency story: idle, over-provisioned, and wasteful resources burn both money and emissions. This skill brings the carbon dimension into FinOps work.
+
+## When to use this skill
+
+Use it when the user mentions carbon, emissions, sustainability, ESG, green/efficient cloud, or wants to pair cost optimization with environmental impact. The waste findings from the `finops-multitool` scans are also carbon findings — surface both.
+
+## Where the data comes from
+
+| Tool | Provides |
+| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
+| **carbon** (finops-multitool) | kgCO2e totals, month-over-month change, 12-month trend, and per-subscription breakdown. Start here in a multitool-driven session. |
+| **Emissions Impact Dashboard (EID)** | Scope 1/2/3 emissions for the Microsoft Cloud footprint, by service/subscription/time |
+| **Azure carbon optimization** | Per-resource emissions estimates and reduction recommendations in the portal |
+| **Cloud for Sustainability** | Broader org-level sustainability data model |
+
+Reference: https://learn.microsoft.com/azure/carbon-optimization/
+
+## Cost and carbon overlap
+
+The same actions reduce both — lead with these because they need no trade-off:
+
+| Action | Cost effect | Carbon effect |
+| --------------------------------------------------------------------------- | ----------- | ----------------------------- |
+| Delete orphaned/idle resources (orphaned resources, idle vms) | ↓ spend | ↓ emissions (nothing running) |
+| Rightsize over-provisioned VMs | ↓ spend | ↓ emissions (less compute) |
+| Increase utilization / consolidate | ↓ unit cost | ↓ emissions per unit |
+| Shut down non-prod off-hours | ↓ spend | ↓ emissions |
+
+Where they diverge: a **low-carbon region** may cost more, and **reservations** cut cost without changing emissions (same hardware runs). Be explicit when a recommendation trades one for the other.
+
+## Carbon-aware levers
+
+- **Region choice** — Azure regions vary widely in carbon intensity; new workloads can prefer low-carbon regions where latency/data-residency allow.
+- **Scheduling** — shift flexible/batch workloads to times or regions with cleaner energy.
+- **Efficient SKUs** — newer hardware generations deliver more work per watt; pair with rightsizing.
+- **Storage tiering** — cooler tiers for cold data reduce both cost and energy.
+
+## Reporting carbon alongside cost
+
+When the user reports cost optimization, offer the carbon co-benefit: "removing these 3 idle VMs saves ~$X/mo and ~Y kgCO₂e/mo." This strengthens the business case and ties FinOps to ESG goals. Pull the cost figure from the multitool scans and the emissions figure from EID/carbon optimization for the same resources.
+
+## Hand-offs
+
+- The wasteful resources (cost side) → `finops-multitool` (orphaned resources, idle vms, storage tier advice).
+- Express carbon-per-unit → `unit-economics` skill.
+- Put cost + carbon co-benefits in a report → `finops-reporting`.
diff --git a/src/templates/agent-skills/unit-economics/SKILL.md b/src/templates/agent-skills/unit-economics/SKILL.md
new file mode 100644
index 000000000..6e33c2841
--- /dev/null
+++ b/src/templates/agent-skills/unit-economics/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: unit-economics
+description: Use when the user wants to measure cost efficiency rather than raw spend — cost per customer/transaction/unit, Effective Savings Rate (ESR), commitment coverage and utilization rates, waste percentage, or any FinOps "quantify business value" KPI that ties cloud cost to a business or usage metric.
+license: MIT
+compatibility: Requires cost data (Cost Management scope or a FinOps hub) and a business/usage denominator (customers, transactions, requests, GB, etc.). Pairs with the finops-toolkit KQL skill and the finops-multitool skill.
+metadata:
+ author: microsoft
+ version: "1.0"
+---
+
+# Unit economics
+
+Move the conversation from "how much did we spend" to "how efficiently did we spend it." This skill defines and calculates the KPIs in the FinOps Framework's *Quantify business value* domain.
+
+## When to use this skill
+
+Use it when the user asks about cost efficiency, cost per unit, ROI of optimization, savings rate, coverage/utilization, or wants a metric that pairs cost with a business denominator. For the raw cost inputs, pull from the `finops-toolkit` KQL queries or the `finops-multitool` scans, then compute the ratio here.
+
+## Core KPIs
+
+| KPI | Formula | Reads from |
+|-----|---------|-----------|
+| **Unit cost** | EffectiveCost ÷ business unit (customers, txns, orders, GB) | Cost + a usage/business metric |
+| **Effective Savings Rate (ESR)** | (ListCost − EffectiveCost) ÷ ListCost | `savings-summary-report.kql` |
+| **Commitment coverage** | Cost covered by RI/SP ÷ total commitment-eligible cost | commitment utilization, `commitment-discount-utilization.kql` |
+| **Commitment utilization** | Used commitment ÷ purchased commitment | commitment utilization |
+| **Waste %** | Idle/orphaned cost ÷ total cost | orphaned resources, idle vms |
+| **Allocation coverage** | Allocated cost ÷ total cost | cost by tag, `cost-allocation` skill |
+| **Forecast accuracy** | 1 − |actual − forecast| ÷ actual | `forecasting-budgeting` skill |
+
+## ESR — the headline rate KPI
+
+ESR is the single best measure of rate-optimization maturity. It captures *all* discount levers (reservations, savings plans, negotiated/contracted prices, Hybrid Benefit) in one number.
+
+- Use `EffectiveCost` vs `ListCost` — not `BilledCost`, which already nets commitments.
+- Track ESR as a trend, not a point. A rising ESR means discounts are compounding; a falling ESR means coverage is decaying (often an expiring reservation).
+- Pair a falling ESR with commitment utilization to find the expiring or under-covered commitment.
+
+## Defining a unit metric
+
+The hard part is the denominator. Guide the user to:
+1. Pick a metric finance and product both accept (active customers, paid transactions, processed GB).
+2. Decide the cost scope that maps to it (whole product, one service tier, one workload tag).
+3. Use allocated cost (from the `cost-allocation` skill) as the numerator so the unit cost reflects true ownership, including shared-cost splits.
+4. Trend it monthly. Falling unit cost while volume grows = healthy economies of scale; rising unit cost = investigate before scaling further.
+
+## Presenting
+
+- Always show the trend and the denominator alongside the ratio — a unit cost with no volume context is misleading.
+- Tie each KPI to an action: high waste % → workload optimization; low coverage → `rate-optimization-portfolio`; low allocation coverage → tagging/policy work.
+
+## Hand-offs
+
+- Cost inputs → `finops-toolkit` / `finops-multitool`.
+- Allocated numerator → `cost-allocation` skill.
+- Coverage/utilization detail → `rate-optimization-portfolio` skill.
+- Visualize KPIs → `power-bi-finops`; narrate them → `finops-reporting`.
diff --git a/src/templates/claude-plugin/skills/anomaly-investigation b/src/templates/claude-plugin/skills/anomaly-investigation
new file mode 120000
index 000000000..5f0642882
--- /dev/null
+++ b/src/templates/claude-plugin/skills/anomaly-investigation
@@ -0,0 +1 @@
+../../agent-skills/anomaly-investigation
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/azure-policy-governance b/src/templates/claude-plugin/skills/azure-policy-governance
new file mode 120000
index 000000000..b77a8e757
--- /dev/null
+++ b/src/templates/claude-plugin/skills/azure-policy-governance
@@ -0,0 +1 @@
+../../agent-skills/azure-policy-governance
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/azure-workbooks-finops b/src/templates/claude-plugin/skills/azure-workbooks-finops
new file mode 120000
index 000000000..c1848a6ac
--- /dev/null
+++ b/src/templates/claude-plugin/skills/azure-workbooks-finops
@@ -0,0 +1 @@
+../../agent-skills/azure-workbooks-finops
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/cost-allocation b/src/templates/claude-plugin/skills/cost-allocation
new file mode 120000
index 000000000..052255ee2
--- /dev/null
+++ b/src/templates/claude-plugin/skills/cost-allocation
@@ -0,0 +1 @@
+../../agent-skills/cost-allocation
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/cost-data-source b/src/templates/claude-plugin/skills/cost-data-source
new file mode 120000
index 000000000..3f4031e1e
--- /dev/null
+++ b/src/templates/claude-plugin/skills/cost-data-source
@@ -0,0 +1 @@
+../../agent-skills/cost-data-source
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/finops-multitool b/src/templates/claude-plugin/skills/finops-multitool
new file mode 120000
index 000000000..b677a1999
--- /dev/null
+++ b/src/templates/claude-plugin/skills/finops-multitool
@@ -0,0 +1 @@
+../../agent-skills/finops-multitool
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/finops-reporting b/src/templates/claude-plugin/skills/finops-reporting
new file mode 120000
index 000000000..638d83735
--- /dev/null
+++ b/src/templates/claude-plugin/skills/finops-reporting
@@ -0,0 +1 @@
+../../agent-skills/finops-reporting
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/focus-data-quality b/src/templates/claude-plugin/skills/focus-data-quality
new file mode 120000
index 000000000..a29785403
--- /dev/null
+++ b/src/templates/claude-plugin/skills/focus-data-quality
@@ -0,0 +1 @@
+../../agent-skills/focus-data-quality
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/forecasting-budgeting b/src/templates/claude-plugin/skills/forecasting-budgeting
new file mode 120000
index 000000000..a7387141f
--- /dev/null
+++ b/src/templates/claude-plugin/skills/forecasting-budgeting
@@ -0,0 +1 @@
+../../agent-skills/forecasting-budgeting
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/power-bi-finops b/src/templates/claude-plugin/skills/power-bi-finops
new file mode 120000
index 000000000..117fabb7c
--- /dev/null
+++ b/src/templates/claude-plugin/skills/power-bi-finops
@@ -0,0 +1 @@
+../../agent-skills/power-bi-finops
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/rate-optimization-portfolio b/src/templates/claude-plugin/skills/rate-optimization-portfolio
new file mode 120000
index 000000000..74de12a79
--- /dev/null
+++ b/src/templates/claude-plugin/skills/rate-optimization-portfolio
@@ -0,0 +1 @@
+../../agent-skills/rate-optimization-portfolio
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/sustainability-carbon b/src/templates/claude-plugin/skills/sustainability-carbon
new file mode 120000
index 000000000..8d1773376
--- /dev/null
+++ b/src/templates/claude-plugin/skills/sustainability-carbon
@@ -0,0 +1 @@
+../../agent-skills/sustainability-carbon
\ No newline at end of file
diff --git a/src/templates/claude-plugin/skills/unit-economics b/src/templates/claude-plugin/skills/unit-economics
new file mode 120000
index 000000000..c20191e84
--- /dev/null
+++ b/src/templates/claude-plugin/skills/unit-economics
@@ -0,0 +1 @@
+../../agent-skills/unit-economics
\ No newline at end of file