Automate and manage FinOps solutions and capabilities.
🦾 Bicep Registry
Official repository for Bicep modules.
diff --git a/docs/multitool.md b/docs/multitool.md
new file mode 100644
index 000000000..c5f59cce8
--- /dev/null
+++ b/docs/multitool.md
@@ -0,0 +1,104 @@
+---
+layout: default
+title: FinOps Multitool
+browser: FinOps Multitool - Scan your Azure environment for FinOps insights
+nav_order: 52
+description: 'The FinOps Multitool scans an Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI or an MCP server for AI agents.'
+permalink: /multitool
+#customer intent: As a FinOps practitioner, I need to learn about the FinOps Multitool
+---
+
+
FinOps Multitool
+Scan your Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI or an MCP server for AI agents.
+{: .fs-6 .fw-300 }
+
+
Install
+
Documentation
+
+---
+
+The FinOps Multitool scans an Azure environment for cost optimization, governance, and FinOps insights and grounds its findings in your live resource state. It surfaces cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance—from an interactive terminal UI or as tools an AI agent can call.
+
+
+
New in the FinOps toolkitv15
+
+ The FinOps Multitool is a new addition to the FinOps toolkit. It delivers 30 read-only scan modules through a cross-platform terminal UI and an MCP server for AI agents, with a scalable FinOps Hub Kusto data path for large environments.
+
+
See all changes
+
+
+
+
+## Explore the Multitool
+
+
+
+
🖥️ Terminal UI
+
Run cost, governance, and optimization scans from an interactive, cross-platform terminal experience.
+
Learn more
+
+
+
🤖 MCP server
+
Expose the scan engine as tools for AI agents like GitHub Copilot over the Model Context Protocol.
+
Learn more
+
+
+
🏦 FinOps Hub data paths
+
Query the hub's Azure Data Explorer or Fabric database directly and push aggregation into the engine to scale to large environments.
+
Learn more
+
+
+
🛡️ Write-safety policy
+
Optional remediation tools are dry-run by default and gated by a configurable write-safety policy. The server is read-only out of the box.
+
Learn more
+
+
+
+
+
+
+
+## Install the module
+
+
+
+
1️⃣ Install PowerShell 7+
+
FinOps toolkit requires PowerShell 7, which is built into Azure Cloud Shell and supported on all major operating systems.
+
+
+
+
2️⃣ Install modules and sign in
+
+
+
Install-Module -Name Az.Accounts
+ Install-Module -Name Az.ResourceGraph
+ Install-Module -Name FinOpsToolkit
+ Connect-AzAccount
+
+
+
+
+
+
+
3️⃣ Launch the Multitool
+
You're now ready to scan. Run the command, then choose the subscriptions and modules to scan.
+
+
+
Start-FinOpsMultitool
+
+
+
+
+
+
+
+
+
About the commands
+
💜 Give feedback
+
+
diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
new file mode 100644
index 000000000..da1e6395e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
@@ -0,0 +1,94 @@
+# 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 or MCP server.
+#
+# 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 'Deploy-ResourceTag.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 'Deploy-PolicyAssignment.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..6466104ca
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1
@@ -0,0 +1,2441 @@
+# 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
+ $banner = @"
+
+ ╔════════════════════════════════════════════════════════════════════════╗
+ ║ ║
+ ║ ███████╗██╗███╗ ██╗ ██████╗ ██████╗ ███████╗ ║
+ ║ ██╔════╝██║████╗ ██║██╔═══██╗██╔══██╗██╔════╝ ║
+ ║ █████╗ ██║██╔██╗ ██║██║ ██║██████╔╝███████╗ ║
+ ║ ██╔══╝ ██║██║╚██╗██║██║ ██║██╔═══╝ ╚════██║ ║
+ ║ ██║ ██║██║ ╚████║╚██████╔╝██║ ███████║ ║
+ ║ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝ ║
+ ║ ║
+ ║ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗████████╗ ██████╗ ██████╗ ██╗ ║
+ ║ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║╚══██╔══╝██╔═══██╗██╔═══██╗██║ ║
+ ║ ██╔████╔██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
+ ║ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
+ ║ ║
+ ║ Azure FinOps Scanner & Optimizer v2.3.0 ║
+ ║ ║
+ ╚════════════════════════════════════════════════════════════════════════╝
+
+"@
+ Write-Host $banner -ForegroundColor Cyan
+ }
+
+ # =====================================================================
+ # 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) {
+ [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 80) { $tLine = $tLine.Substring(0, 77) + '...' }
+ Write-Host $tLine.PadRight(85) -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, [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
+ [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 70) { $line = $line.Substring(0, 67) + '...' }
+ Write-Host $line.PadRight(75) -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, [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 as the MCP server (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/LICENSE b/src/powershell/Private/FinOpsMultitool/LICENSE
new file mode 100644
index 000000000..dd0ab5585
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zac Larsen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md
new file mode 100644
index 000000000..6fdb4a8c2
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/README.md
@@ -0,0 +1,522 @@
+# 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/). When using the MCP server, you ask in natural language and the agent calls the matching tool. A few examples of prompt → output:
+
+### Percentage of Legacy Resource → `scan_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 → `scan_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 → `scan_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 → `scan_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 `scan_cost_data`, `Carbon per Unit of Spend` = total emissions ÷ monthly spend.
+
+### Commitment Utilization Score / % Discount Waste → `scan_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 → `scan_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 (TUI) or pass `dataSource=api` (MCP).
+
+#### 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
+```
+
+## MCP Server (AI Integration)
+
+The FinOps Multitool includes an MCP (Model Context Protocol) server that exposes all 30 scan modules — plus a `run_full_scan` composite — as AI-callable tools, along with a set of **remediation (write) tools** that act on the findings. This lets Copilot, Claude, custom agents, and SRE automation call the same functions used by the TUI.
+
+Read scans are always safe. The write tools are gated by a configurable [write-safety policy](#write-safety-remediation-tools) so neither a person nor an autonomous agent can make a costly mistake — every write previews first (dry-run) and, in autonomous mode, requires a single-use confirmation token bound to the exact change.
+
+### Setup
+
+For a standalone `.vscode/mcp.json` (workspace or user level), use a top-level `servers` block:
+
+```json
+{
+ "servers": {
+ "finops-multitool": {
+ "type": "stdio",
+ "command": "pwsh",
+ "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"]
+ }
+ }
+}
+```
+
+For VS Code `settings.json`, nest the same under an `mcp` key:
+
+```json
+{
+ "mcp": {
+ "servers": {
+ "finops-multitool": {
+ "type": "stdio",
+ "command": "pwsh",
+ "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"]
+ }
+ }
+ }
+}
+```
+
+### Available Tools
+
+| Tool | Description |
+| ----------------------------- | ----------------------------------------------------- |
+| `scan_orphaned_resources` | Find unattached disks, NICs, public IPs, NSGs |
+| `scan_idle_vms` | Find VMs with <5% CPU over 14-30 days |
+| `scan_storage_tier_advice` | Storage accounts that could use cooler tiers |
+| `scan_ahb_opportunities` | VMs/SQL not using Azure Hybrid Benefit |
+| `scan_tag_inventory` | Tag coverage %, tag names, resource counts |
+| `scan_tag_recommendations` | Inconsistent casing, missing standard tags |
+| `scan_policy_inventory` | Policy assignments with compliance status |
+| `scan_policy_recommendations` | Policy coverage gaps for cost governance |
+| `scan_cost_data` | Actual + forecasted cost per subscription |
+| `scan_resource_costs` | Top resources by cost (MTD) |
+| `scan_cost_by_tag` | Spend breakdown by tag key/value |
+| `scan_cost_trend` | Month-over-month spend comparison |
+| `scan_reservation_advice` | RI purchase recommendations |
+| `scan_commitment_utilization` | RI and Savings Plan usage rates |
+| `scan_savings_realized` | Actual savings from commitments |
+| `scan_budget_status` | Budget consumption vs thresholds |
+| `scan_anomaly_alerts` | Recent cost anomaly detections |
+| `scan_legacy_resources` | Legacy/retiring SKUs needing modernization |
+| `scan_unit_economics` | Cost per vCPU, per VM, and per GB stored |
+| `scan_ai_workloads` | AI token consumption, cost per 1K tokens, per call |
+| `scan_carbon` | Cloud carbon emissions and month-over-month trend |
+| `scan_optimization_advice` | Azure Advisor cost recommendations |
+| `scan_billing_structure` | Billing account hierarchy |
+| `scan_contract_info` | Agreement type, offer, support plan |
+| `explore_finops_kpis` | Browse the FinOps Foundation KPIs this server informs |
+| `run_full_scan` | Run all modules — comprehensive assessment |
+
+### Remediation Tools (write actions)
+
+These tools **change Azure resources**. They are dry-run by default and route through the [write-safety policy](#write-safety-remediation-tools) below. Each acts on a single resource ID returned by the matching read scan.
+
+| Tool | Action | Reversible |
+| ------------------------------------ | ------------------------------------------------------------------------------------- | ---------------------- |
+| `remediate_enable_hybrid_benefit` | Enable Azure Hybrid Benefit on a VM (from `scan_ahb_opportunities`) | Yes (set back to None) |
+| `remediate_deallocate_vm` | Deallocate an idle VM (from `scan_idle_vms`) | Yes (start the VM) |
+| `remediate_delete_orphaned_resource` | Delete an orphaned disk / NIC / public IP / snapshot (from `scan_orphaned_resources`) | No (delete) |
+
+Each write tool takes `resourceId` (required), `apply` (default `false` = preview), and `confirmationToken` (required only in Enforced mode). The delete tool only accepts an allow-list of safe-to-delete types and re-verifies the resource is still orphaned before acting.
+
+### FinOps KPI Insights
+
+Most users do not know the [FinOps Foundation KPI catalog](https://www.finops.org/finops-kpis/) by name, so the server surfaces it for them. Every scan automatically attaches a `kpiInsights` block that maps the result to the industry KPIs it informs, with a computed value where the data allows:
+
+```json
+"kpiInsights": [
+ { "kpiId": "cost-per-gb-stored", "kpiName": "Cost per Gigabyte Stored",
+ "domain": "Quantify", "status": "computed", "yourValue": "USD 0.0108 per GB / month",
+ "plainLanguage": "What you pay for each GB of stored data this month.",
+ "exploreHint": "Run scan_storage_tier_advice to see if cooler tiers lower this.",
+ "learnMore": "https://www.finops.org/finops-kpis/" }
+]
+```
+
+`status` is `computed` when a real value was derived from the scan, or `informational` when the scan relates to the KPI but the value needs another scan or external input — the server never fabricates a number. Call `explore_finops_kpis` (no arguments) to list the informable KPIs grouped by FinOps domain, or pass a `kpiId` for a single KPI's definition and which tool produces it. This first release covers ~27 KPIs across Understand, Quantify, Optimize, and Manage.
+
+### Resources
+
+| URI | Description |
+| ---------------------- | ----------------------------------- |
+| `finops://permissions` | Required RBAC roles per scan module |
+| `finops://modules` | List of all available scan modules |
+
+### Architecture
+
+```
+AI Agent (Copilot / Claude / SRE Agent)
+ │ MCP Protocol (stdio JSON-RPC)
+ ▼
+Start-McpServer.ps1
+ │ Imports FinOpsMultitool.psm1
+ ▼
+Get-CostData, Get-TagInventory, etc. ◄── read scans
+Remove-OrphanedResource, Stop-IdleVm… ◄── write tools → Resolve-WriteDecision (safety gate)
+ │ Same functions used by the TUI
+ ▼
+Azure APIs (Cost Management, Resource Graph, Advisor, etc.)
+```
+
+## Write Safety (Remediation Tools)
+
+The remediation tools are designed for two audiences at once — a person chatting through an AI client, **and** an autonomous agent running unattended — without forcing the safety burden on the interactive experience. Behavior is controlled by environment variables, so the same server is friendly in a chat and locked-down in production. The server is **read-only by default** (`ReadOnly`); enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`.
+
+### 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",
+ "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"],
+ "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
+├── Start-McpServer.ps1 # MCP server (AI integration, stdio JSON-RPC)
+├── 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/Start-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1
new file mode 100644
index 000000000..a91d4b398
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1
@@ -0,0 +1,1797 @@
+###########################################################################
+# START-MCPSERVER.PS1
+# FINOPS MULTITOOL MCP SERVER (STDIO)
+###########################################################################
+# Purpose: Model Context Protocol server exposing FinOps scan modules
+# as AI-callable tools over JSON-RPC via stdin/stdout.
+# Author: Zac Larsen
+# Date: Created for FinOps Toolkit integration
+#
+# Description:
+# 1. Imports FinOpsMultitool.psm1 (same module used by TUI/GUI)
+# 2. Listens for JSON-RPC messages on stdin
+# 3. Dispatches tool calls to existing Get-* functions
+# 4. Returns structured JSON results on stdout
+#
+# Prerequisites:
+# - PowerShell 7+ (pwsh)
+# - Az.Accounts, Az.Resources, Az.ResourceGraph modules
+# - Active Azure session (Connect-AzAccount)
+#
+# Usage:
+# MCP config (VS Code settings.json or mcp.json):
+# {
+# "mcp": {
+# "servers": {
+# "finops-multitool": {
+# "command": "pwsh",
+# "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"]
+# }
+# }
+# }
+# }
+###########################################################################
+
+$ErrorActionPreference = 'Stop'
+
+# ---------------------------------------------------------------------
+# Keep stdout clean for JSON-RPC. Scan modules use Write-Host for TUI
+# status and Az cmdlets emit warnings/progress — in a redirected child
+# process these land on stdout and corrupt the protocol stream. Suppress
+# every non-JSON stream globally and shadow Write-Host so it can never
+# reach stdout. Errors (stream 2) are left intact for try/catch.
+# ---------------------------------------------------------------------
+$WarningPreference = 'SilentlyContinue'
+$VerbosePreference = 'SilentlyContinue'
+$DebugPreference = 'SilentlyContinue'
+$ProgressPreference = 'SilentlyContinue'
+$InformationPreference = 'SilentlyContinue'
+
+# Write-Host bypasses preference variables and writes straight to the
+# host (= stdout when redirected). Shadow it with a function that routes
+# to stderr, so module TUI output is preserved for debugging but never
+# pollutes the JSON-RPC stdout stream.
+function Write-Host {
+ [CmdletBinding()]
+ param(
+ [Parameter(Position = 0, ValueFromPipeline, ValueFromRemainingArguments)]
+ [object[]]$Object,
+ [switch]$NoNewline,
+ [object]$Separator,
+ [object]$ForegroundColor,
+ [object]$BackgroundColor
+ )
+ if ($null -ne $Object) { [Console]::Error.WriteLine(($Object -join ' ')) }
+}
+
+# Import the module
+$psm1Path = Join-Path $PSScriptRoot 'FinOpsMultitool.psm1'
+if (-not (Test-Path $psm1Path)) {
+ [Console]::Error.WriteLine("ERROR: FinOpsMultitool.psm1 not found at $psm1Path")
+ exit 1
+}
+Import-Module $psm1Path -Force -DisableNameChecking
+
+# =====================================================================
+# MCP PROTOCOL CONSTANTS
+# =====================================================================
+$MCP_VERSION = '2024-11-05'
+$SERVER_NAME = 'finops-multitool'
+$SERVER_VERSION = '1.3.0'
+
+# =====================================================================
+# TOOL DEFINITIONS
+# =====================================================================
+# Each tool maps to a Get-* function in the module. The MCP server
+# handles subscription resolution and parameter binding.
+
+$toolDefinitions = @(
+ @{
+ name = 'scan_orphaned_resources'
+ description = 'Find orphaned Azure resources (unattached disks, NICs, public IPs, NSGs) across subscriptions.'
+ fn = 'Get-OrphanedResources'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'remediate_delete_orphaned_resource'
+ description = "WRITE/MUTATING. Delete ONE orphaned resource found by scan_orphaned_resources. Only orphan-eligible types can ever be deleted: unattached managed disks (Microsoft.Compute/disks), dangling public IPs (Microsoft.Network/publicIPAddresses), unattached NICs (Microsoft.Network/networkInterfaces), and disk snapshots (Microsoft.Compute/snapshots) - any other type is refused. It re-reads the resource and re-verifies it is still orphaned before acting (it REFUSES if the resource is now in use). DRY-RUN by default: without apply=true it returns a preview (the exact DELETE URI plus orphan evidence) and deletes NOTHING. Deletion is IRREVERSIBLE. ALWAYS show the user the preview and obtain explicit confirmation, THEN call again with apply=true to actually delete. Requires a delete-capable role (e.g. Contributor) on the resource scope."
+ fn = 'Remove-OrphanedResource'
+ isWrite = $true
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the orphan to delete (e.g. /subscriptions/{guid}/resourceGroups/{rg}/providers/Microsoft.Compute/disks/{name}). Take this from scan_orphaned_resources output. Required.' }
+ apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview (deletes nothing). Set true ONLY after the user has reviewed the preview and explicitly approved deleting this specific resource. Deletion is irreversible.' }
+ confirmationToken = @{ type = 'string'; description = 'The token returned by the dry-run preview. OPTIONAL in Interactive mode; REQUIRED in Enforced (autonomous-safe) mode, where apply=true is rejected without the exact token from the matching preview. Single-use and short-lived.' }
+ }
+ required = @('resourceId')
+ }
+ }
+ @{
+ name = 'remediate_enable_hybrid_benefit'
+ description = "WRITE/MUTATING (REVERSIBLE, savings-only). Enable Azure Hybrid Benefit on ONE VM found by scan_ahb_opportunities, applying existing Windows Server / SQL licenses to cut compute licensing cost up to ~85%. This is reversible (set licenseType back to None) and only reduces cost. Windows VMs are auto-detected (licenseType Windows_Server); for Linux you must pass an explicit RHEL_BYOS/SLES_BYOS licenseType. No-ops if AHB is already on. DRY-RUN by default: without apply=true it previews the PATCH and changes nothing. In Interactive mode apply=true works directly; in Enforced mode it also requires the confirmationToken from the preview."
+ fn = 'Enable-HybridBenefit'
+ isWrite = $true
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the VM (Microsoft.Compute/virtualMachines). From scan_ahb_opportunities. Required.' }
+ licenseType = @{ type = 'string'; enum = @('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS'); description = 'Optional override. Auto-detected as Windows_Server for Windows VMs; required for Linux (RHEL_BYOS/SLES_BYOS).' }
+ apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview. Set true to enable AHB. Reversible, savings-only.' }
+ confirmationToken = @{ type = 'string'; description = 'Token from the dry-run preview. Optional in Interactive mode; required in Enforced mode.' }
+ }
+ required = @('resourceId')
+ }
+ }
+ @{
+ name = 'remediate_deallocate_vm'
+ description = "WRITE/MUTATING (REVERSIBLE). Deallocate (stop) ONE 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 configuration; it is NOT deleted. No-ops if the VM is already deallocated. DRY-RUN by default: without apply=true it previews the deallocate and changes nothing. In Interactive mode apply=true works directly; in Enforced mode it also requires the confirmationToken from the preview."
+ fn = 'Stop-IdleVm'
+ isWrite = $true
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the VM (Microsoft.Compute/virtualMachines). From scan_idle_vms. Required.' }
+ apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview. Set true to deallocate. Reversible (start the VM to undo).' }
+ confirmationToken = @{ type = 'string'; description = 'Token from the dry-run preview. Optional in Interactive mode; required in Enforced mode.' }
+ }
+ required = @('resourceId')
+ }
+ }
+ @{
+ name = 'scan_idle_vms'
+ description = 'Find idle or underutilized VMs (less than 5% average CPU over 14-30 days) with cost impact classification.'
+ fn = 'Get-IdleVMs'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_storage_tier_advice'
+ description = 'Analyze storage accounts for tier optimization opportunities (Hot to Cool/Cold/Archive).'
+ fn = 'Get-StorageTierAdvice'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_ahb_opportunities'
+ description = 'Find Windows/SQL VMs and SQL databases not using Azure Hybrid Benefit (up to 40-55% savings).'
+ fn = 'Get-AHBOpportunities'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_tag_inventory'
+ description = 'Inventory all resource tags across subscriptions. Returns tag coverage percentage, tag names, resource counts, and untagged resources.'
+ fn = 'Get-TagInventory'
+ category = 'Governance'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_tag_recommendations'
+ description = 'Analyze existing tags and recommend improvements: missing CAF standard tags, inconsistent casing, similar/duplicate names.'
+ fn = 'Get-TagRecommendations'
+ category = 'Governance'
+ requiresTagInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_policy_inventory'
+ description = 'List all Azure Policy assignments with scope, effect, enforcement mode, and compliance status.'
+ fn = 'Get-PolicyInventory'
+ category = 'Governance'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_policy_recommendations'
+ description = 'Evaluate policy coverage gaps and recommend cost governance policies (tagging, region, SKU restrictions).'
+ fn = 'Get-PolicyRecommendations'
+ category = 'Governance'
+ requiresPolicyInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_data'
+ description = 'Get current month actual and forecasted cost per subscription. Returns spend, forecast, and currency. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.'
+ fn = 'Get-CostData'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." }
+ }
+ }
+ }
+ @{
+ name = 'scan_resource_costs'
+ description = 'Get top resources by cost (actual month-to-date spend) with resource group, type, and forecast. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.'
+ fn = 'Get-ResourceCosts'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_by_tag'
+ description = 'Break down cost by tag key/value pairs. Shows spend per tag value and identifies untagged spend. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.'
+ fn = 'Get-CostByTag'
+ category = 'Cost Analysis'
+ requiresTagInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_trend'
+ description = 'Get month-over-month cost trend (last 3-6 months) per subscription to identify spending patterns.'
+ fn = 'Get-CostTrend'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_reservation_advice'
+ description = 'Get Azure Advisor reservation purchase recommendations with estimated annual savings.'
+ fn = 'Get-ReservationAdvice'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_commitment_utilization'
+ description = 'Check utilization rates of existing reservations and savings plans. Identifies underutilized commitments.'
+ fn = 'Get-CommitmentUtilization'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_savings_realized'
+ description = 'Calculate actual savings from reservations, savings plans, and Azure Hybrid Benefit (monthly and annual).'
+ fn = 'Get-SavingsRealized'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_budget_status'
+ description = 'Check budget consumption vs thresholds. Returns budget amounts, actual spend, percentage used, and risk level.'
+ fn = 'Get-BudgetStatus'
+ category = 'Monitoring'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_budget_history'
+ description = 'Get monthly budget vs actual history (last N months) for each configured budget. Pro-rates quarterly/annual budgets to a monthly equivalent. Runs Budget Status first to discover budgets.'
+ fn = 'Get-BudgetHistory'
+ category = 'Monitoring'
+ requiresBudgets = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ monthsBack = @{ type = 'integer'; description = 'Number of months of history to return. Default 6.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_anomaly_alerts'
+ description = 'Retrieve recent cost anomaly alerts and detection rules.'
+ fn = 'Get-AnomalyAlerts'
+ category = 'Monitoring'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_optimization_advice'
+ description = 'Get Azure Advisor cost optimization recommendations with estimated annual savings per resource.'
+ fn = 'Get-OptimizationAdvice'
+ category = 'Advisor'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_unit_economics'
+ description = 'Compute unit-economics KPIs: cost per vCPU, per GB RAM, per VM (compute) and per GB stored (storage), plus the compute/storage cost split, by dividing month-to-date amortized cost by provisioned capacity. vCPU and RAM are exact (from Compute SKU capabilities); storage GB combines managed disks with Storage-account used capacity (Azure Monitor); cost is scoped to the selected subscriptions with a per-subscription fallback.'
+ fn = 'Get-UnitEconomics'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_vm_cost_breakdown'
+ description = "Decompose ONE virtual machine's full solution cost (month-to-date) into meter components - compute, OS/data disks, network egress, network infra (public IP/NIC), backup/recovery, security (Defender), monitoring/extension agents, and licensing - instead of a single rolled-up number. Resolves the VM and its associated billable resources from Azure Resource Graph, then attributes cost per meter. Uses the FinOps Hub export when available (exact egress GB); otherwise the live Cost Management API. Provide vmName (optionally resourceGroup) or a full resourceId."
+ fn = 'Get-VmCostBreakdown'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ vmName = @{ type = 'string'; description = 'Name of the virtual machine to decompose. Use with resourceGroup if the name is not unique.' }
+ resourceId = @{ type = 'string'; description = 'Full Azure resource ID of the VM. Takes precedence over vmName when supplied.' }
+ resourceGroup = @{ type = 'string'; description = 'Resource group of the VM (disambiguates a non-unique vmName).' }
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, searches all accessible subscriptions.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost. 'auto' (default) uses the FinOps Hub export when it covers scope (exact egress GB), else the live Cost Management API. 'hub' forces the export. 'api' forces the live API." }
+ }
+ }
+ }
+ @{
+ name = 'scan_allocate_shared_cost'
+ description = "Allocate the billed cost of SHARED hub resources (ExpressRoute gateway/circuit, VPN gateway, Azure Firewall, shared bandwidth) across the spoke subscriptions that use them, and report each spoke's full solution cost (its own resources + its allocated share). Use this for hub-and-spoke showback. The cost math lives here; the GB/TB transfer split key is supplied as input - an agent can fetch per-spoke transfer from Azure Monitor / Traffic Analytics (e.g. via the Azure MCP server) and pass it as weightingValues, or fall back to a proxy (resourceCount/equal). Each shared pool is split into a fixed part (shared evenly) and a variable part (by transfer weight). Provide sharedResourceIds or sharedResourceGroup, plus the spoke subscription IDs."
+ fn = 'Get-SharedCostAllocation'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ sharedResourceIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Full resource IDs of the shared hub resources to allocate (e.g. the ExpressRoute gateway, firewall). Either this or sharedResourceGroup is required.' }
+ sharedResourceGroup = @{ type = 'string'; description = 'Resource group holding the shared resources. Used when sharedResourceIds is not supplied.' }
+ spokes = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subscription IDs of the spokes that share the hub resources. Required.' }
+ weightingMethod = @{ type = 'string'; enum = @('inline', 'trafficAnalytics', 'equal', 'resourceCount'); description = "How to weight the variable split. 'inline' (default) uses weightingValues (exact GB/TB per spoke). 'trafficAnalytics' queries a Log Analytics workspace (workspaceId) for measured GB per spoke - exact, end-to-end, no manual feed. 'equal' splits evenly. 'resourceCount' uses billable resource count per spoke as a proxy estimate." }
+ weightingValues = @{ type = 'object'; description = "Map of spoke subscription id -> transfer amount (e.g. GB). Used when weightingMethod is 'inline'. Typically sourced from Traffic Analytics / Azure Monitor." }
+ workspaceId = @{ type = 'string'; description = "Log Analytics workspace GUID (customer id). Required when weightingMethod is 'trafficAnalytics'. The workspace must have Traffic Analytics / VNet flow logs." }
+ lookbackDays = @{ type = 'number'; description = "Days of transfer history to read for 'trafficAnalytics' weighting. Default 30." }
+ fixedRatio = @{ type = 'number'; description = 'Fraction of each shared pool treated as fixed (split evenly) vs variable (split by weight). 0 = all variable, 1 = all fixed. Default 0.5.' }
+ subscriptionId = @{ type = 'string'; description = 'Optional scope hint for resolving shared resources. If omitted, the spokes and shared resource scope are searched.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost. 'auto' (default) uses the FinOps Hub export when it covers scope, else the live Cost Management API. 'hub' forces the export. 'api' forces the live API." }
+ }
+ }
+ }
+ @{
+ name = 'set_cost_allocation_rule'
+ description = "WRITE/MUTATING. Create or update a NATIVE Azure Cost Management cost allocation rule so chargeback reflects a shared-cost split (typically the output of scan_allocate_shared_cost). This CHANGES how cost is charged back across subscriptions and can affect internal billing. It is DRY-RUN by default: without apply=true it returns a preview (the exact PUT URI and request body) and writes NOTHING. ALWAYS show the user the preview and obtain explicit confirmation, THEN call again with apply=true to actually write. Requires an EA enrollment or MCA billing account id and Cost Management Contributor on it. Source is one hub resource group (sourceResourceGroup) or subscription (sourceSubscriptionId); targets are the spoke subscriptions with percentages (auto-normalized to sum 100)."
+ fn = 'Set-CostAllocationRule'
+ isWrite = $true
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ billingAccountId = @{ type = 'string'; description = 'EA enrollment id or MCA billing account id that scopes the rule. Required.' }
+ ruleName = @{ type = 'string'; description = "Cost allocation rule name. Letters, digits, '_' and '-' only (max 260 chars). Required." }
+ sourceResourceGroup = @{ type = 'array'; items = @{ type = 'string' }; description = 'Resource group name(s) holding the shared cost to reallocate (the hub). Provide this OR sourceSubscriptionId.' }
+ sourceSubscriptionId = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subscription id(s) holding the shared cost to reallocate (the hub). Provide this OR sourceResourceGroup.' }
+ targets = @{ type = 'array'; items = @{ type = 'object' }; description = "Spoke targets. Each item: { subscriptionId, percentage } or { spoke, allocatedShared }. You can pass the Allocations array from scan_allocate_shared_cost (it has Spoke + AllocatedShared) and percentages are derived and normalized to sum 100. Required." }
+ targetDimension = @{ type = 'string'; enum = @('SubscriptionId', 'ResourceGroupName'); description = "Dimension the targets are keyed by. Default 'SubscriptionId'." }
+ status = @{ type = 'string'; enum = @('Active', 'NotActive'); description = "Rule status. 'Active' (default) impacts cost allocation; 'NotActive' saves it without applying." }
+ description = @{ type = 'string'; description = 'Optional rule description.' }
+ apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview (writes nothing). Set true ONLY after the user has reviewed the preview and explicitly approved, to create/update the rule in Azure.' }
+ confirmationToken = @{ type = 'string'; description = 'Token returned by the dry-run preview. OPTIONAL in Interactive mode; REQUIRED in Enforced (autonomous-safe) mode, where apply=true is rejected without the exact token from the matching preview. Single-use and short-lived.' }
+ }
+ required = @('billingAccountId', 'ruleName', 'targets')
+ }
+ }
+ @{
+ name = 'scan_billing_account'
+ description = "READ-ONLY. List the Azure billing accounts your identity can see and report, for each, the billing account id (the value set_cost_allocation_rule needs), the agreement type (EA / MCA / CSP / pay-as-you-go), whether it is ELIGIBLE for cost allocation rules (only EA and MCA are), and - by default - whether you can reach the cost allocation rules endpoint there (a 403 means you lack Cost Management Contributor at that scope). Run this before set_cost_allocation_rule to find the right billingAccountId and confirm access. Never writes anything."
+ fn = 'Get-BillingAccount'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ billingAccountId = @{ type = 'string'; description = 'Optional. Return only this billing account id instead of all visible ones.' }
+ probeAccess = @{ type = 'boolean'; description = 'When true (default), probe the cost allocation rules endpoint per eligible account to report read access (a 403 signals missing Cost Management Contributor). Set false to skip the extra calls.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_usage_allocation'
+ description = "SHOWBACK. Split the billed cost of a SHARED PLATFORM across sub-resource consumers that have NO Azure billing dimension - Kubernetes namespaces (AKS), APIM products/subscriptions (token spend), or Azure OpenAI deployments - by a usage signal read from a Log Analytics workspace. This is the answer to 'we can't get to AKS/APIM token spend easily': there is no native billing line per namespace/token, so it must be telemetry-driven. Pick a preset (aksNamespace = Container Insights CPU/mem; apimTokens = App Insights token metrics; openAiTokens = Azure Monitor Cognitive Services token metrics) or pass a custom weightingQuery returning Consumer + Weight. SHOWBACK ONLY: results are Mode=Showback with empty RuleTargets and CANNOT be written via set_cost_allocation_rule (those consumers are not EA/MCA billing dimensions). Provide the pool via sharedResourceIds/sharedResourceGroup (cost resolved) or poolAmount, plus the workspaceId."
+ fn = 'Get-UsageProportionalAllocation'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ preset = @{ type = 'string'; enum = @('aksNamespace', 'apimTokens', 'openAiTokens', 'custom'); description = "Which shared-platform usage key to use. 'aksNamespace' splits by namespace CPU+memory (Container Insights). 'apimTokens' splits by tokens per APIM consumer (workspace-based App Insights). 'openAiTokens' splits by tokens per Azure OpenAI resource (Azure Monitor metrics). 'custom' requires weightingQuery." }
+ workspaceId = @{ type = 'string'; description = 'Log Analytics workspace GUID holding the telemetry (Container Insights workspace for AKS; the workspace backing Application Insights for APIM/OpenAI). Required.' }
+ sharedResourceIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Resource IDs whose billed cost forms the pool to split (e.g. the AKS cluster / its node pools, the APIM instance, the AOAI resource). Either this or sharedResourceGroup or poolAmount.' }
+ sharedResourceGroup = @{ type = 'string'; description = 'Resource group holding the shared platform; its billed cost forms the pool.' }
+ poolAmount = @{ type = 'number'; description = 'Explicit pool cost to split instead of resolving resources (e.g. a known PTU monthly cost).' }
+ lookbackDays = @{ type = 'number'; description = 'Telemetry window in days. Default 30.' }
+ dimensionName = @{ type = 'string'; description = "Override the consumer dimension. apimTokens defaults to 'Subscription Id'; set e.g. 'API ID' or 'Product Id' to charge by API or product." }
+ weightingQuery = @{ type = 'string'; description = 'Full KQL override. Must return two columns: Consumer (string) and Weight (number). Required when preset is custom.' }
+ subscriptionId = @{ type = 'string'; description = 'Optional scope hint for resolving the shared platform resources.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read the pool cost. 'auto' (default) uses the FinOps Hub export when it covers scope, else the live Cost Management API." }
+ }
+ required = @('preset', 'workspaceId')
+ }
+ }
+ @{
+ name = 'scan_ai_workloads'
+ description = 'Detect AI/LLM workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) and, only when present, compute AI unit-economics KPIs: month-to-date token consumption (input/output) by model deployment, total Azure OpenAI requests, AI spend, effective cost per 1K tokens, and cost per request. Self-gating - returns quickly when no AI workloads exist. Reads AI spend and billed token volume from the FinOps Hub export when available (no Monitor/Cost Management calls); cost per request is only available on the live API path.'
+ fn = 'Get-AIWorkloadMetrics'
+ category = 'AI & ML'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read AI spend and token volume. 'auto' (default) uses the FinOps Hub export when it fully covers scope, else the live Monitor + Cost Management APIs. 'hub' forces the export (no cost-per-request). 'api' forces the live APIs." }
+ }
+ }
+ }
+ @{
+ name = 'scan_legacy_resources'
+ description = 'Find legacy and retiring resources to modernize: first-generation (v1) VM families, unmanaged (VHD) disks, HDD managed disks, and Basic-SKU public IPs / load balancers (retiring Sep 2025).'
+ fn = 'Get-LegacyResources'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_carbon'
+ description = 'Get Azure carbon emissions (kgCO2e) from the Carbon Optimization service: latest-month total, month-over-month change, a 12-month trend, and a per-subscription breakdown. Emissions publish ~2 months in arrears.'
+ fn = 'Get-CarbonMetrics'
+ category = 'Sustainability'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_billing_structure'
+ description = 'Get billing account hierarchy and enrollment details (EA, MCA, CSP).'
+ fn = 'Get-BillingStructure'
+ category = 'Account'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_contract_info'
+ description = 'Get agreement type, offer details, currency, and support plan information.'
+ fn = 'Get-ContractInfo'
+ category = 'Account'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_macc_commitment'
+ description = 'Check Microsoft Azure Consumption Commitment (MACC) status for EA/MCA billing accounts: commitment amount, consumed, remaining, percent burned, and expiration. Returns not-applicable for PAYGO/CSP/MSDN. Requires a billing role.'
+ fn = 'Get-MaccCommitment'
+ category = 'Account'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'get_azure_context'
+ description = 'Show the ACTIVE Azure sign-in context this server will scan: account, tenant, current subscription, and every tenant/subscription the account can reach. ALWAYS call this first in a session (and any time the user is unsure) so the user can confirm they are pointed at the intended tenant BEFORE running any scan or remediation. Scans only ever touch the active context shown here. If it is wrong, the user must switch context (Set-AzContext / Connect-AzAccount -TenantId) and RESTART this MCP server — the server caches its Azure session at startup.'
+ fn = '_get_context'
+ category = 'Context'
+ inputSchema = @{
+ type = 'object'
+ properties = @{}
+ }
+ }
+ @{
+ name = 'detect_cost_data_source'
+ description = 'Decide how cost scans should run BEFORE invoking any cost tool. Detects a FinOps Hub or any readable Cost Management (CSV) export in scope, checks whether it is readable (with a specific blocker reason if not), reports which subscriptions it covers and how fresh the data is, and estimates how long the live Cost Management API path would take. Call this first for any cost question so the fast export path can be used, or so the user can be warned and asked before a slow API scan.'
+ fn = '_detect_cost_source'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, evaluates all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'run_full_scan'
+ description = 'Run all FinOps scan modules (optimization, governance, cost, commitments, monitoring, advisor) and return a comprehensive assessment. This is the most thorough scan — use individual tools for targeted queries.'
+ fn = '_full_scan'
+ category = 'Assessment'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ modules = @{ type = 'array'; items = @{ type = 'string' }; description = 'Optional list of module names to include. Omit to run all.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = 'Cost-family data source. auto (default) uses a FinOps Hub or Cost Management export when it covers the scope, else the live Cost Management API; hub forces the export fast path; api skips the export. Governance and optimization modules always use live APIs.' }
+ }
+ }
+ }
+ @{
+ name = 'generate_powerbi_template'
+ description = 'Run a full FinOps scan and generate a self-contained Power BI template (.pbit) plus the supporting CSV files. The .pbit ships a curated 4-page report (Cost Overview, Subscriptions, Optimization, Governance) whose CsvFolderPath parameter is pre-pointed at the exported folder. Returns the path to FinOps-Report.pbit.'
+ fn = '_generate_powerbi'
+ category = 'Reporting'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions in the current tenant.' }
+ outputDir = @{ type = 'string'; description = 'Folder to write the CSVs and FinOps-Report.pbit into. Defaults to a timestamped folder under the user TEMP directory.' }
+ dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = 'Cost-family data source for the scan. auto (default), hub, or api.' }
+ }
+ }
+ }
+ @{
+ name = 'connect_powerbi_to_hub'
+ description = 'Detect the FinOps Hub in scope and emit the exact connection parameter values to plug into the official FinOps Toolkit Power BI reports (src/power-bi). Returns the storage account, blob endpoint, ingestion container, dataset path, and which report to open. Use this when the user already has a FinOps Hub and wants the toolkit reports instead of a generated .pbit.'
+ fn = '_connect_powerbi_hub'
+ category = 'Reporting'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, evaluates all accessible subscriptions in the current tenant.' }
+ }
+ }
+ }
+ @{
+ name = 'explore_finops_kpis'
+ description = 'Browse the FinOps Foundation KPIs (https://www.finops.org/finops-kpis/) that this server can inform, and learn which scan produces each one. Call with no arguments to list KPIs grouped by FinOps domain (Understand/Quantify/Optimize/Manage) with a Computable-now vs Informational flag. Call with a kpiId to get that KPI''s definition, the tool that informs it, and a plain-language explanation. Use this when a user wants to understand FinOps KPIs or map their tenant data to industry metrics. Most scan tools also attach a kpiInsights block to their own output automatically.'
+ fn = '_explore_kpis'
+ category = 'Guidance'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ kpiId = @{ type = 'string'; description = 'Optional KPI id (from the list) to get full detail. Omit to list all informable KPIs grouped by domain.' }
+ }
+ }
+ }
+)
+
+# Permission requirements per tool (same as TUI)
+$permissionMap = @{
+ 'Get-OrphanedResources' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Remove-OrphanedResource' = @{ role = 'Contributor (delete)'; scope = 'Resource'; api = 'Azure Resource Manager (DELETE)' }
+ 'Enable-HybridBenefit' = @{ role = 'Virtual Machine Contributor'; scope = 'Resource'; api = 'Azure Resource Manager (PATCH)' }
+ 'Stop-IdleVm' = @{ role = 'Virtual Machine Contributor'; scope = 'Resource'; api = 'Azure Resource Manager (deallocate)' }
+ 'Get-IdleVMs' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph + Monitor Metrics' }
+ 'Get-StorageTierAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-AHBOpportunities' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-TagInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-TagRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-PolicyInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' }
+ 'Get-PolicyRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' }
+ 'Get-CostData' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-ResourceCosts' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-CostByTag' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-CostTrend' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-ReservationAdvice' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Recommendations API' }
+ 'Get-CommitmentUtilization' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Summaries API' }
+ 'Get-SavingsRealized' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Benefit Utilization API' }
+ 'Get-BudgetStatus' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Budgets API' }
+ 'Get-BudgetHistory' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Query API' }
+ 'Get-AnomalyAlerts' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Alerts API' }
+ 'Get-OptimizationAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Advisor API' }
+ 'Get-CarbonMetrics' = @{ role = 'Reader or Carbon Optimization Reader'; scope = 'Subscription'; api = 'Carbon Optimization API' }
+ 'Get-LegacyResources' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-UnitEconomics' = @{ role = 'Cost Management Reader + Reader'; scope = 'Management Group'; api = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics' }
+ 'Get-AIWorkloadMetrics' = @{ role = 'Cost Management Reader + Reader'; scope = 'Management Group'; api = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API' }
+ 'Get-BillingStructure' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' }
+ 'Get-ContractInfo' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' }
+ 'Get-MaccCommitment' = @{ role = 'Billing Reader or EA Reader'; scope = 'Billing Account'; api = 'Consumption Lots API' }
+}
+
+# =====================================================================
+# RESOURCE DEFINITIONS
+# =====================================================================
+$resourceDefinitions = @(
+ @{
+ uri = 'finops://permissions'
+ name = 'Permission Requirements'
+ description = 'Required Azure RBAC roles for each scan module.'
+ mimeType = 'application/json'
+ }
+ @{
+ uri = 'finops://modules'
+ name = 'Available Scan Modules'
+ description = 'List of all FinOps scan modules with descriptions and categories.'
+ mimeType = 'application/json'
+ }
+)
+
+# =====================================================================
+# HELPER: RESOLVE SUBSCRIPTIONS
+# =====================================================================
+function Resolve-Subscriptions {
+ param([string]$SubscriptionId)
+
+ if ($SubscriptionId) {
+ $sub = Get-AzSubscription -SubscriptionId $SubscriptionId -ErrorAction Stop
+ return @($sub)
+ }
+
+ $subs = @(Get-AzSubscription -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' })
+ if ($subs.Count -eq 0) { throw 'No enabled subscriptions found. Run Connect-AzAccount first.' }
+ return $subs
+}
+
+# =====================================================================
+# HELPER: ACTIVE AZURE CONTEXT (TENANT SAFETY)
+# =====================================================================
+# Every cost/governance scan runs against whatever Az session this MCP
+# server process holds — which is NOT necessarily the tenant the user
+# thinks they're in (the server caches its context at startup, and an
+# account can span many tenants). To stop anyone acting on results from
+# the wrong tenant, Get-AzContextSummary returns a cheap, no-network
+# snapshot of the active account/tenant/subscription that is attached to
+# EVERY tool result. Get-AzContextDetail does the fuller, enumerating
+# version (accessible tenants + multi-tenant warning) for the dedicated
+# get_azure_context tool.
+$script:MultiTenantFlag = $null # lazily computed once, then reused cheaply
+
+function Get-AzContextSummary {
+ # No network calls — just the in-memory Az context. Safe to call on
+ # every single tool invocation.
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+ if (-not $ctx) {
+ return [ordered]@{
+ signedIn = $false
+ summary = 'NO ACTIVE AZURE SESSION. Results below may be empty. Run Connect-AzAccount, then restart this MCP server.'
+ verify = 'No tenant context is set. Do not trust scan results until signed in.'
+ }
+ }
+ $tenantId = $ctx.Tenant.Id
+ $multiHint = if ($null -ne $script:MultiTenantFlag -and $script:MultiTenantFlag) {
+ ' Your account can see MULTIPLE tenants — confirm this is the intended one.'
+ }
+ else { '' }
+ return [ordered]@{
+ signedIn = $true
+ account = $ctx.Account.Id
+ tenantId = $tenantId
+ subscriptionName = $ctx.Subscription.Name
+ subscriptionId = $ctx.Subscription.Id
+ environment = $ctx.Environment.Name
+ summary = "This scan ran as $($ctx.Account.Id) against tenant $tenantId, subscription '$($ctx.Subscription.Name)' ($($ctx.Subscription.Id))."
+ verify = "Confirm this is the correct tenant/subscription BEFORE acting on these results.$multiHint Call get_azure_context for the full account/tenant picture."
+ }
+}
+
+function Get-AzContextDetail {
+ # Fuller view used by the get_azure_context tool. Enumerates the
+ # subscriptions/tenants the signed-in account can reach so the caller
+ # can spot a cross-tenant situation. Caches the multi-tenant flag so
+ # the cheap per-result summary can warn without re-enumerating.
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+ if (-not $ctx) {
+ $script:MultiTenantFlag = $false
+ return [ordered]@{
+ signedIn = $false
+ message = 'No active Azure session. Run Connect-AzAccount and restart this MCP server, then re-run.'
+ }
+ }
+
+ $allSubs = @()
+ try { $allSubs = @(Get-AzSubscription -ErrorAction SilentlyContinue) } catch { }
+ $tenants = @($allSubs | Select-Object -ExpandProperty TenantId -Unique | Where-Object { $_ })
+ $script:MultiTenantFlag = ($tenants.Count -gt 1)
+
+ $activeTenant = $ctx.Tenant.Id
+ $warning = if ($script:MultiTenantFlag) {
+ "Your account can access $($tenants.Count) tenants ($($tenants -join ', ')). " +
+ "Scans run ONLY against the ACTIVE context (tenant $activeTenant). If that is not the tenant you intend, " +
+ "switch with Set-AzContext (or Connect-AzAccount -TenantId ) and RESTART this MCP server before scanning."
+ }
+ else { $null }
+
+ return [ordered]@{
+ signedIn = $true
+ account = $ctx.Account.Id
+ activeTenantId = $activeTenant
+ activeSubscriptionName = $ctx.Subscription.Name
+ activeSubscriptionId = $ctx.Subscription.Id
+ environment = $ctx.Environment.Name
+ accessibleTenants = $tenants
+ accessibleSubscriptionCount = $allSubs.Count
+ multiTenant = $script:MultiTenantFlag
+ warning = $warning
+ summary = "Signed in as $($ctx.Account.Id). Active = tenant $activeTenant / subscription '$($ctx.Subscription.Name)' ($($ctx.Subscription.Id)). This is the ONLY scope scans will touch."
+ }
+}
+
+
+# =====================================================================
+# HELPER: COST DATA SOURCE (HUB) CACHE
+# =====================================================================
+# The MCP server reads the FinOps Hub export at most once per
+# subscription set, then serves spend-breakdown cost tools from that
+# single read. This keeps cost scans fast and avoids repeated Cost
+# Management API round-trips. Governance/optimization tools are NOT
+# routed here — they always use the live Resource Graph / ARM path.
+$script:McpHubCache = @{}
+
+function Get-McpHubData {
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subs,
+ [string]$TenantId
+ )
+
+ $key = (@($Subs.Id) | Sort-Object) -join ','
+ if ($script:McpHubCache.ContainsKey($key)) { return $script:McpHubCache[$key] }
+
+ $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($Subs.Id) -TenantId $TenantId
+
+ # Scalable hub path: resolve a Kusto provider (ftklocal override, a
+ # discovered ADX/Fabric cluster, or none). When one is available we push
+ # aggregation into the engine and NEVER read raw rows into PowerShell -
+ # this is what lets the tool scale to large (tens of GB) hub datasets.
+ $provider = Resolve-FOHubProvider -Subscriptions @($Subs.Id) -Decision $decision
+
+ $raw = $null
+ if (-not $provider.Found -and $decision.Readable -and $decision.Hub) {
+ try {
+ $raw = Read-FinOpsHubData -StorageAccountName $decision.Hub.Name -ResourceGroupName $decision.Hub.ResourceGroup -Months 1
+ }
+ catch {
+ $raw = $null
+ }
+ }
+
+ # No readable hub, but the resolver found a generic Cost Management
+ # export covering the scope — read its CSV data (newest run per sub,
+ # overlapping subs deduped) so the cost tools can serve from it.
+ $exportData = $null
+ if (-not $provider.Found -and
+ (-not $raw -or @($raw).Count -eq 0) -and
+ $decision.ExportFound -and
+ $decision.Recommendation -in @('UseExport', 'UseExportPartial') -and
+ (Get-Command Get-MergedCostExportData -ErrorAction SilentlyContinue)) {
+ try {
+ $exportData = Get-MergedCostExportData -Exports $decision.Exports
+ }
+ catch {
+ $exportData = $null
+ }
+ }
+
+ $entry = @{ Decision = $decision; Provider = $provider; Raw = $raw; ExportData = $exportData }
+ $script:McpHubCache[$key] = $entry
+ return $entry
+}
+
+# =====================================================================
+# HELPER: MAP FULL-SCAN RESULTS -> POWER BI SCAN-DATA SHAPE
+# =====================================================================
+# New-PowerBITemplate consumes the same hashtable shape the GUI keeps in
+# $script:scanData. The full-scan result stores each module's raw output
+# under its tool name, so the mapping is a direct key lookup.
+function ConvertTo-PbiScanData {
+ param([Parameter(Mandatory)][object]$FullScan)
+
+ $r = $FullScan.results
+ if (-not $r) { $r = @{} }
+
+ return @{
+ Auth = @{
+ TenantId = (Get-AzContext).Tenant.Id
+ Subscriptions = @($FullScan.subscriptions | ForEach-Object { @{ Name = $_.name; Id = $_.id } })
+ }
+ Costs = $r['scan_cost_data']
+ ResourceCosts = $r['scan_resource_costs']
+ Tags = $r['scan_tag_inventory']
+ TagRecs = $r['scan_tag_recommendations']
+ PolicyInv = $r['scan_policy_inventory']
+ PolicyRecs = $r['scan_policy_recommendations']
+ Budgets = $r['scan_budget_status']
+ Orphans = $r['scan_orphaned_resources']
+ CostByTag = $r['scan_cost_by_tag']
+ CostTrend = $r['scan_cost_trend']
+ Commitments = $r['scan_commitment_utilization']
+ AHB = $r['scan_ahb_opportunities']
+ Optimization = $r['scan_optimization_advice']
+ Reservations = $r['scan_reservation_advice']
+ Savings = $r['scan_savings_realized']
+ }
+}
+
+# =====================================================================
+# HELPER: HUB -> OFFICIAL POWER BI REPORT CONNECTION PARAMETERS
+# =====================================================================
+# Shapes a Resolve-CostDataSource decision into the parameter values a
+# user plugs into the FinOps Toolkit's official Power BI reports
+# (src/power-bi). The Storage reports read the hub's 'ingestion'
+# container; the KQL reports read the Data Explorer cluster. The exact
+# clusterUri / storageUrlForPowerBI values come from the hub deployment
+# Outputs, so we surface what we can infer plus where to copy the rest.
+function Get-HubPbiConnection {
+ param([Parameter(Mandatory)][object]$Decision)
+
+ if (-not $Decision.HubFound -or -not $Decision.Hub) {
+ return @{
+ hubFound = $false
+ recommendation = 'NoHub'
+ message = 'No FinOps Hub found in scope. Deploy a FinOps Hub (https://aka.ms/finops/hubs) to use the official Power BI reports, or use generate_powerbi_template for a live-scan report.'
+ }
+ }
+
+ $acct = $Decision.Hub.Name
+ $storageUrl = "https://$acct.dfs.core.windows.net/ingestion"
+
+ return @{
+ hubFound = $true
+ readable = $Decision.Readable
+ recommendation = $Decision.Recommendation
+ coveragePct = $Decision.CoveragePct
+ asOf = $Decision.Freshness
+ hub = @{
+ storageAccount = $acct
+ resourceGroup = $Decision.Hub.ResourceGroup
+ subscriptionId = $Decision.Hub.SubscriptionId
+ location = $Decision.Hub.Location
+ }
+ reportParameters = @{
+ # Storage-based reports (Cost summary, Rate optimization, etc.)
+ storageUrlForPowerBI = $storageUrl
+ ingestionContainer = 'ingestion'
+ # KQL/ADX-based reports (Data ingestion, etc.) — copy from hub Outputs
+ clusterUri = ''
+ }
+ reports = @(
+ @{ name = 'Cost summary'; dataSource = 'Storage'; download = 'https://aka.ms/finops/toolkit/CostSummary.pbix' }
+ @{ name = 'Rate optimization'; dataSource = 'Storage'; download = 'https://aka.ms/finops/toolkit/RateOptimization.pbix' }
+ @{ name = 'Data ingestion'; dataSource = 'KQL'; download = 'https://aka.ms/finops/toolkit/DataIngestion.pbix' }
+ )
+ instructions = @(
+ "Download the FinOps Toolkit Power BI reports: https://aka.ms/finops/toolkit/reports",
+ "Open a report in Power BI Desktop. When prompted, set 'Storage URL' to: $storageUrl",
+ "For KQL reports, set 'Cluster URI' to the clusterUri value from the hub resource group's deployment Outputs.",
+ "Authorize the storage source with an account that has Storage Blob Data Reader (or a SAS token).",
+ "Leave 'Number of Months' empty to load all data, then Apply."
+ )
+ message = $Decision.Message
+ }
+}
+
+# =====================================================================
+# HELPER: INVOKE TOOL
+# =====================================================================
+function Invoke-McpTool {
+ param(
+ [string]$ToolName,
+ [hashtable]$Arguments
+ )
+
+ $toolDef = $toolDefinitions | Where-Object { $_.name -eq $ToolName }
+ if (-not $toolDef) { throw "Unknown tool: $ToolName" }
+
+ # Targeted remediation acts on ONE resource id - it does not enumerate
+ # subscriptions. Safe-by-default: dry-run unless apply=true is explicit.
+ if ($toolDef.fn -eq 'Remove-OrphanedResource') {
+ $rid = [string]$Arguments.resourceId
+ if (-not $rid) { throw 'resourceId is required for remediate_delete_orphaned_resource.' }
+ $doApply = ($Arguments.apply -eq $true)
+ $confToken = if ($Arguments.confirmationToken) { [string]$Arguments.confirmationToken } else { $null }
+ $remResult = Remove-OrphanedResource -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken
+ return @{
+ tool = $ToolName
+ module = 'Remove-OrphanedResource'
+ category = $toolDef.category
+ data = $remResult
+ source = 'LiveApi'
+ permission = if ($permissionMap.ContainsKey('Remove-OrphanedResource')) { $permissionMap['Remove-OrphanedResource'] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ # Targeted reversible remediation (enable AHB / deallocate VM) - one
+ # resource id, no subscription enumeration, routed through the gate.
+ if ($toolDef.fn -in @('Enable-HybridBenefit', 'Stop-IdleVm')) {
+ $rid = [string]$Arguments.resourceId
+ if (-not $rid) { throw "resourceId is required for $ToolName." }
+ $doApply = ($Arguments.apply -eq $true)
+ $confToken = if ($Arguments.confirmationToken) { [string]$Arguments.confirmationToken } else { $null }
+ $remResult = if ($toolDef.fn -eq 'Enable-HybridBenefit') {
+ $lt = if ($Arguments.licenseType) { [string]$Arguments.licenseType } else { $null }
+ if ($lt) { Enable-HybridBenefit -ResourceId $rid -LicenseType $lt -Apply:$doApply -ConfirmationToken $confToken }
+ else { Enable-HybridBenefit -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken }
+ }
+ else {
+ Stop-IdleVm -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken
+ }
+ return @{
+ tool = $ToolName
+ module = $toolDef.fn
+ category = $toolDef.category
+ data = $remResult
+ source = 'LiveApi'
+ permission = if ($permissionMap.ContainsKey($toolDef.fn)) { $permissionMap[$toolDef.fn] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ $subId = if ($Arguments.subscriptionId) { $Arguments.subscriptionId } else { $null }
+ $subIdSubset = if ($Arguments.subscriptionIds) { @($Arguments.subscriptionIds) } else { $null }
+
+ # Resolve the working subscription set, honouring an explicit subset
+ # (used by the agent to chunk large tenants for incremental progress).
+ $resolveSubs = {
+ if ($subIdSubset) { @($subIdSubset | ForEach-Object { Get-AzSubscription -SubscriptionId $_ -ErrorAction Stop }) }
+ else { Resolve-Subscriptions -SubscriptionId $subId }
+ }
+
+ # Full scan is a composite tool
+ if ($toolDef.fn -eq '_full_scan') {
+ $ds = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' }
+ return Invoke-FullScan -SubscriptionId $subId -ModuleFilter $Arguments.modules -DataSource $ds
+ }
+
+ # KPI catalog exploration is a static guidance helper, not a scan
+ if ($toolDef.fn -eq '_explore_kpis') {
+ $kpiId = if ($Arguments.kpiId) { [string]$Arguments.kpiId } else { $null }
+ return @{
+ tool = $ToolName
+ module = 'Get-KpiExploration'
+ category = $toolDef.category
+ data = (Get-KpiExploration -KpiId $kpiId)
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ # Active-context check is a tenant-safety helper, not a scan. It runs
+ # without resolving subscriptions so it works even when the wrong (or
+ # no) subscription is selected.
+ if ($toolDef.fn -eq '_get_context') {
+ return @{
+ tool = $ToolName
+ module = 'Get-AzContextDetail'
+ category = $toolDef.category
+ data = (Get-AzContextDetail)
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ # Cost data source detection is a routing helper, not a scan
+ if ($toolDef.fn -eq '_detect_cost_source') {
+ $subs = & $resolveSubs
+ $tenantId = (Get-AzContext).Tenant.Id
+ $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($subs.Id) -TenantId $tenantId
+ return @{
+ tool = $ToolName
+ module = 'Resolve-CostDataSource'
+ category = $toolDef.category
+ data = $decision
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ # Power BI template generation: full scan -> CSVs + .pbit
+ if ($toolDef.fn -eq '_generate_powerbi') {
+ $ds = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' }
+ $scan = Invoke-FullScan -SubscriptionId $subId -DataSource $ds
+ $pbiScanData = ConvertTo-PbiScanData -FullScan $scan
+
+ $outDir = if ($Arguments.outputDir) {
+ [string]$Arguments.outputDir
+ }
+ else {
+ $stamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
+ Join-Path ([System.IO.Path]::GetTempPath()) "FinOps-PowerBI-$stamp"
+ }
+
+ $skel = Join-Path (Join-Path $PSScriptRoot 'assets') 'skeleton.pbit'
+ $built = New-PowerBITemplate -ScanData $pbiScanData -OutputDir $outDir -SkeletonPath $skel
+
+ return @{
+ tool = $ToolName
+ module = 'New-PowerBITemplate'
+ category = $toolDef.category
+ data = @{
+ pbitPath = $built.PbitPath
+ csvCount = $built.CsvCount
+ outputDir = $built.OutputDir
+ subscriptions = $scan.subscriptions
+ costDataSource = $scan.costDataSource
+ }
+ note = "Open $($built.PbitPath) in Power BI Desktop. The CsvFolderPath parameter is pre-set to the exported folder; move the folder and the .pbit together or update the parameter."
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ # Power BI hub connection: detect hub and emit toolkit-report params
+ if ($toolDef.fn -eq '_connect_powerbi_hub') {
+ $subs = & $resolveSubs
+ $tenantId = (Get-AzContext).Tenant.Id
+ $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($subs.Id) -TenantId $tenantId
+ return @{
+ tool = $ToolName
+ module = 'Resolve-CostDataSource'
+ category = $toolDef.category
+ data = (Get-HubPbiConnection -Decision $decision)
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ $fn = $toolDef.fn
+ $subs = & $resolveSubs
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # -----------------------------------------------------------------
+ # Export-first routing for spend-breakdown cost tools. When a
+ # readable FinOps Hub export covers the requested scope, serve these
+ # from the export (one read, cached) instead of the Cost Management
+ # API. dataSource: auto (default) | hub | api.
+ # auto -> hub only when the resolver recommends UseHub, else API
+ # hub -> force hub (error if unreadable/empty)
+ # api -> skip hub entirely
+ # Only Get-CostData / Get-ResourceCosts / Get-CostByTag have hub
+ # converters; all other cost-family tools stay on the live API.
+ # -----------------------------------------------------------------
+ if ($fn -in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')) {
+ $requested = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' }
+
+ $hubInfo = $null
+ if ($requested -ne 'api') { $hubInfo = Get-McpHubData -Subs $subs -TenantId $tenantId }
+
+ # Scalable Kusto path (ADX / Fabric / ftklocal): aggregation is pushed
+ # into the engine and only summaries come back, so this is preferred
+ # over the row-reader whenever a cluster is available. On a provider
+ # error we fall through to the storage/export/API paths below.
+ if ($hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found -and $requested -ne 'api') {
+ $prov = $hubInfo.Provider
+ # Match the storage path's scope: serve the whole hub (its coverage
+ # IS its scope). Per-subscription filtering is a follow-up.
+ $kustoResult = switch ($fn) {
+ 'Get-CostData' { Get-FOHubCostSummary -Provider $prov }
+ 'Get-ResourceCosts' { Get-FOHubResourceCosts -Provider $prov }
+ 'Get-CostByTag' { Get-FOHubCostByTag -Provider $prov }
+ }
+ if (-not ($kustoResult -is [System.Collections.IDictionary] -and $kustoResult.Contains('Error') -and $kustoResult.Error)) {
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = $kustoResult
+ source = 'FinOpsHubKusto'
+ asOf = $hubInfo.Decision.Freshness
+ coveragePct = $hubInfo.Decision.CoveragePct
+ note = "Served from the FinOps Hub Kusto database ($($prov.Mode); aggregation pushed into the engine, summaries only). Forecast is not included; call with dataSource=api for live forecast."
+ permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+ }
+
+ $useHub = $false
+ if ($hubInfo -and $hubInfo.Raw -and @($hubInfo.Raw).Count -gt 0) {
+ if ($requested -eq 'hub') { $useHub = $true }
+ elseif ($requested -eq 'auto' -and $hubInfo.Decision.Recommendation -eq 'UseHub') { $useHub = $true }
+ }
+
+ # Generic Cost Management export fast path: no hub, but a readable
+ # CSV export covers the scope. Same data contract as the hub path,
+ # so dataSource=hub also accepts it (it is the materialized fast path).
+ $useExport = $false
+ if (-not $useHub -and $hubInfo -and $hubInfo.ExportData -and @($hubInfo.ExportData.Rows).Count -gt 0) {
+ $rec = $hubInfo.Decision.Recommendation
+ if ($requested -eq 'hub') { $useExport = $true }
+ elseif ($requested -eq 'auto' -and $rec -in @('UseExport', 'UseExportPartial')) { $useExport = $true }
+ }
+
+ if ($requested -eq 'hub' -and -not $useHub -and -not $useExport) {
+ $d = if ($hubInfo) { $hubInfo.Decision } else { $null }
+ if ($hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found) {
+ # A Kusto cluster was available but the query did not return
+ # usable data (it errored above and we fell through here).
+ throw "Hub data requested but the FinOps Hub Kusto query ($($hubInfo.Provider.Mode), $($hubInfo.Provider.ClusterUri)) did not return data. Re-run with dataSource=api to use the live Cost Management API."
+ }
+ $reason = if ($d) { "$($d.ReadBlocker): $($d.ReadBlockerDetail)" } else { 'no hub or export found in scope' }
+ throw "Export data requested but unavailable ($reason). $($d.RemediationHint)"
+ }
+
+ if ($useHub) {
+ $hubResult = switch ($fn) {
+ 'Get-CostData' { ConvertTo-CostDataFromHub -HubData $hubInfo.Raw }
+ 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromHub -HubData $hubInfo.Raw }
+ 'Get-CostByTag' {
+ $tagInv = ConvertTo-TagInventoryFromHub -HubData $hubInfo.Raw
+ $existingTags = if ($tagInv.TagNames) { $tagInv.TagNames } else { @{} }
+ ConvertTo-CostByTagFromHub -HubData $hubInfo.Raw -ExistingTags $existingTags
+ }
+ }
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = $hubResult
+ source = 'FinOpsHub'
+ asOf = $hubInfo.Decision.Freshness
+ coveragePct = $hubInfo.Decision.CoveragePct
+ note = 'Served by the FinOps Hub storage reader (billed actuals, rows aggregated in PowerShell). This is the small-dataset convenience path. For large hubs, deploy/point at a Kusto database (Azure Data Explorer / Fabric, or set FINOPS_HUB_KUSTO_URI for a local ftklocal emulator) so aggregation runs in the engine. Forecast is not included; call with dataSource=api for live forecast.'
+ permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+
+ if ($useExport) {
+ $exp = $hubInfo.ExportData
+ $exportResult = switch ($fn) {
+ 'Get-CostData' { ConvertTo-CostDataFromExport -ExportData $exp -Subscriptions $subs }
+ 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromExport -ExportData $exp -Subscriptions $subs }
+ 'Get-CostByTag' { ConvertTo-CostByTagFromExport -ExportData $exp }
+ }
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = $exportResult
+ source = 'CostManagementExport'
+ asOf = $hubInfo.Decision.Freshness
+ coveragePct = $hubInfo.Decision.CoveragePct
+ note = 'Served by the Cost Management export reader (billed actuals, CSV aggregated in PowerShell). This is the small-dataset convenience path; for large datasets use a FinOps Hub Kusto database (engine-side aggregation). Forecast is a linear month-to-date projection; call with dataSource=api for live forecast.'
+ permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+ # otherwise fall through to the live Cost Management API path below
+ }
+
+ # Build parameter set based on what the function accepts
+ $cmdInfo = Get-Command $fn -ErrorAction Stop
+ $params = @{}
+
+ if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) {
+ $params['Subscriptions'] = $subs
+ }
+ if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) {
+ $params['TenantId'] = $tenantId
+ }
+
+ # VM cost breakdown target args (scan_vm_cost_breakdown)
+ if ($cmdInfo.Parameters.ContainsKey('VmName') -and $Arguments.vmName) {
+ $params['VmName'] = [string]$Arguments.vmName
+ }
+ if ($cmdInfo.Parameters.ContainsKey('ResourceId') -and $Arguments.resourceId) {
+ $params['ResourceId'] = [string]$Arguments.resourceId
+ }
+ if ($cmdInfo.Parameters.ContainsKey('ResourceGroup') -and $Arguments.resourceGroup) {
+ $params['ResourceGroup'] = [string]$Arguments.resourceGroup
+ }
+
+ # Shared cost allocation args (scan_allocate_shared_cost)
+ if ($cmdInfo.Parameters.ContainsKey('SharedResourceIds') -and $Arguments.sharedResourceIds) {
+ $params['SharedResourceIds'] = @($Arguments.sharedResourceIds | ForEach-Object { [string]$_ })
+ }
+ if ($cmdInfo.Parameters.ContainsKey('SharedResourceGroup') -and $Arguments.sharedResourceGroup) {
+ $params['SharedResourceGroup'] = [string]$Arguments.sharedResourceGroup
+ }
+ if ($cmdInfo.Parameters.ContainsKey('Spokes') -and $Arguments.spokes) {
+ $params['Spokes'] = @($Arguments.spokes | ForEach-Object { [string]$_ })
+ }
+ if ($cmdInfo.Parameters.ContainsKey('WeightingMethod') -and $Arguments.weightingMethod) {
+ $params['WeightingMethod'] = [string]$Arguments.weightingMethod
+ }
+ if ($cmdInfo.Parameters.ContainsKey('WeightingValues') -and $null -ne $Arguments.weightingValues) {
+ $params['WeightingValues'] = $Arguments.weightingValues
+ }
+ if ($cmdInfo.Parameters.ContainsKey('WorkspaceId') -and $Arguments.workspaceId) {
+ $params['WorkspaceId'] = [string]$Arguments.workspaceId
+ }
+ if ($cmdInfo.Parameters.ContainsKey('LookbackDays') -and $null -ne $Arguments.lookbackDays) {
+ $params['LookbackDays'] = [int]$Arguments.lookbackDays
+ }
+ if ($cmdInfo.Parameters.ContainsKey('FixedRatio') -and $null -ne $Arguments.fixedRatio) {
+ $params['FixedRatio'] = [double]$Arguments.fixedRatio
+ }
+
+ # Cost allocation rule write-back args (set_cost_allocation_rule).
+ # apply defaults to false (dry-run) - never write unless explicitly true.
+ if ($cmdInfo.Parameters.ContainsKey('BillingAccountId') -and $Arguments.billingAccountId) {
+ $params['BillingAccountId'] = [string]$Arguments.billingAccountId
+ }
+ if ($cmdInfo.Parameters.ContainsKey('RuleName') -and $Arguments.ruleName) {
+ $params['RuleName'] = [string]$Arguments.ruleName
+ }
+ if ($cmdInfo.Parameters.ContainsKey('SourceResourceGroup') -and $Arguments.sourceResourceGroup) {
+ $params['SourceResourceGroup'] = @($Arguments.sourceResourceGroup | ForEach-Object { [string]$_ })
+ }
+ if ($cmdInfo.Parameters.ContainsKey('SourceSubscriptionId') -and $Arguments.sourceSubscriptionId) {
+ $params['SourceSubscriptionId'] = @($Arguments.sourceSubscriptionId | ForEach-Object { [string]$_ })
+ }
+ if ($cmdInfo.Parameters.ContainsKey('Targets') -and $Arguments.targets) {
+ $params['Targets'] = @($Arguments.targets)
+ }
+ if ($cmdInfo.Parameters.ContainsKey('TargetDimension') -and $Arguments.targetDimension) {
+ $params['TargetDimension'] = [string]$Arguments.targetDimension
+ }
+ if ($cmdInfo.Parameters.ContainsKey('Status') -and $Arguments.status) {
+ $params['Status'] = [string]$Arguments.status
+ }
+ if ($cmdInfo.Parameters.ContainsKey('Description') -and $Arguments.description) {
+ $params['Description'] = [string]$Arguments.description
+ }
+ if ($cmdInfo.Parameters.ContainsKey('Apply') -and $Arguments.apply -eq $true) {
+ $params['Apply'] = $true
+ }
+ # Confirmation token for gated write tools that flow through the generic
+ # path (e.g. Set-CostAllocationRule). Required in Enforced mode; the
+ # write-safety gate validates it against the previewed change.
+ if ($cmdInfo.Parameters.ContainsKey('ConfirmationToken') -and $Arguments.confirmationToken) {
+ $params['ConfirmationToken'] = [string]$Arguments.confirmationToken
+ }
+ if ($cmdInfo.Parameters.ContainsKey('ProbeAccess') -and $null -ne $Arguments.probeAccess) {
+ $params['ProbeAccess'] = [bool]$Arguments.probeAccess
+ }
+
+ # Usage-proportional showback args (scan_usage_allocation)
+ if ($cmdInfo.Parameters.ContainsKey('Preset') -and $Arguments.preset) {
+ $params['Preset'] = [string]$Arguments.preset
+ }
+ if ($cmdInfo.Parameters.ContainsKey('PoolAmount') -and $null -ne $Arguments.poolAmount) {
+ $params['PoolAmount'] = [double]$Arguments.poolAmount
+ }
+ if ($cmdInfo.Parameters.ContainsKey('DimensionName') -and $Arguments.dimensionName) {
+ $params['DimensionName'] = [string]$Arguments.dimensionName
+ }
+ if ($cmdInfo.Parameters.ContainsKey('WeightingQuery') -and $Arguments.weightingQuery) {
+ $params['WeightingQuery'] = [string]$Arguments.weightingQuery
+ }
+
+ # Hand the FinOps Hub export to functions that accept -HubData (e.g.
+ # Get-AIWorkloadMetrics). These run their own ARG gate, so they flow
+ # through the normal call path with the export injected rather than the
+ # cost-family hub switch above.
+ if ($cmdInfo.Parameters.ContainsKey('HubData')) {
+ $aiRequested = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' }
+ if ($aiRequested -ne 'api') {
+ $aiHub = Get-McpHubData -Subs $subs -TenantId $tenantId
+ if ($aiHub -and $aiHub.Raw -and @($aiHub.Raw).Count -gt 0) {
+ $useAiHub = ($aiRequested -eq 'hub') -or ($aiRequested -eq 'auto' -and $aiHub.Decision.Recommendation -eq 'UseHub')
+ if ($useAiHub) { $params['HubData'] = $aiHub.Raw }
+ }
+ elseif ($aiRequested -eq 'hub') {
+ $d = if ($aiHub) { $aiHub.Decision } else { $null }
+ $reason = if ($d) { "$($d.ReadBlocker): $($d.ReadBlockerDetail)" } else { 'no hub found in scope' }
+ throw "Hub data requested but unavailable ($reason). $($d.RemediationHint)"
+ }
+ }
+ }
+
+ # Handle chained dependencies
+ if ($toolDef.requiresTagInventory) {
+ $tagData = Get-TagInventory -Subscriptions $subs
+ if ($fn -eq 'Get-TagRecommendations') {
+ $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } }
+ if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations }
+ }
+ elseif ($fn -eq 'Get-CostByTag') {
+ $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} }
+ }
+ }
+ if ($toolDef.requiresPolicyInventory) {
+ $policyData = Get-PolicyInventory -Subscriptions $subs -TenantId $tenantId
+ $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } }
+ }
+ if ($toolDef.requiresBudgets) {
+ # Budget history depends on Budget Status — discover budgets first
+ $budgetData = Get-BudgetStatus -Subscriptions $subs
+ $budgetRows = if ($budgetData.Budgets) { @($budgetData.Budgets) } else { @() }
+ if ($budgetRows.Count -eq 0) {
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = @()
+ source = 'LiveApi'
+ note = 'No budgets configured for the requested scope; no history to report.'
+ permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+ timestamp = (Get-Date -Format 'o')
+ }
+ }
+ $params = @{ Budgets = $budgetRows }
+ if ($Arguments.monthsBack) { $params['MonthsBack'] = [int]$Arguments.monthsBack }
+ }
+
+ # Budget status needs current spend to compute % used / risk. Without it
+ # every budget reports $0 actual and "On Track" regardless of real spend.
+ # Fetch per-subscription cost once and pass it in as -CostData so the
+ # percentages and risk levels are real.
+ if ($fn -eq 'Get-BudgetStatus') {
+ try {
+ $costParams = @{ Subscriptions = $subs }
+ if ($tenantId) { $costParams['TenantId'] = $tenantId }
+ $costMap = Get-CostData @costParams
+ if ($costMap -is [hashtable]) { $params['CostData'] = $costMap }
+ }
+ catch {
+ # Non-fatal: budgets still list. Get-BudgetStatus marks spend as
+ # Unknown rather than asserting On Track when CostData is absent.
+ }
+ }
+
+ # Invoke
+ $result = & $fn @params
+
+ # Add permission context to result
+ $permInfo = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = $result
+ source = 'LiveApi'
+ permission = $permInfo
+ timestamp = (Get-Date -Format 'o')
+ }
+}
+
+# =====================================================================
+# FULL SCAN (composite tool)
+# =====================================================================
+function Invoke-FullScan {
+ param(
+ [string]$SubscriptionId,
+ [string[]]$ModuleFilter,
+ [string]$DataSource = 'auto'
+ )
+
+ $subs = Resolve-Subscriptions -SubscriptionId $SubscriptionId
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # Determine which modules to run. A full scan is a READ-ONLY assessment:
+ # exclude the write/remediation tools (isWrite) and the non-scan meta
+ # helpers (underscore-prefixed fns like _full_scan, _get_context,
+ # _generate_powerbi) so the loop only runs diagnostic scan modules and
+ # never invokes remediation or cost-allocation writes.
+ $modulesToRun = $toolDefinitions | Where-Object { $_.fn -notlike '_*' -and -not $_.isWrite }
+ if ($ModuleFilter -and $ModuleFilter.Count -gt 0) {
+ $modulesToRun = $modulesToRun | Where-Object { $_.name -in $ModuleFilter }
+ }
+
+ $results = @{}
+ $errors = @{}
+
+ # -----------------------------------------------------------------
+ # Export-first routing for cost-family modules (mirrors single-tool
+ # routing in Invoke-McpTool). Resolve the hub once; the three modules
+ # with hub converters serve from the export when usable, else fall
+ # through to the live Cost Management API. Governance/optimization
+ # modules always use live APIs.
+ # auto -> hub only when the resolver recommends UseHub (full cover)
+ # hub -> force hub (per-module live-API fallback if a convert fails)
+ # api -> skip hub entirely
+ # -----------------------------------------------------------------
+ $costFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')
+ $hubInfo = $null
+ $hubUsable = $false
+ if ($DataSource -ne 'api' -and ($modulesToRun | Where-Object { $_.fn -in $costFns })) {
+ $hubInfo = Get-McpHubData -Subs $subs -TenantId $tenantId
+ if ($hubInfo -and $hubInfo.Raw -and @($hubInfo.Raw).Count -gt 0) {
+ if ($DataSource -eq 'hub') { $hubUsable = $true }
+ elseif ($DataSource -eq 'auto' -and $hubInfo.Decision.Recommendation -eq 'UseHub') { $hubUsable = $true }
+ }
+ }
+
+ # Scalable Kusto provider (ADX / Fabric / ftklocal). Preferred over the
+ # row-reader: cost-family modules push aggregation into the engine and
+ # return only summaries, so a large hub never loads rows into PowerShell.
+ $kustoProvider = $null
+ if ($DataSource -ne 'api' -and $hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found) {
+ $kustoProvider = $hubInfo.Provider
+ }
+
+ # Generic Cost Management export fast path (no hub, readable CSV export).
+ $exportUsable = $false
+ if (-not $hubUsable -and -not $kustoProvider -and $DataSource -ne 'api' -and $hubInfo -and $hubInfo.ExportData -and @($hubInfo.ExportData.Rows).Count -gt 0) {
+ $rec = $hubInfo.Decision.Recommendation
+ if ($DataSource -eq 'hub') { $exportUsable = $true }
+ elseif ($DataSource -eq 'auto' -and $rec -in @('UseExport', 'UseExportPartial')) { $exportUsable = $true }
+ }
+ $hubServed = @{}
+
+ # Run Tag Inventory first (other modules depend on it)
+ $tagData = $null
+ $tagTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-TagInventory' }
+ if ($tagTool) {
+ try {
+ $tagData = Get-TagInventory -Subscriptions $subs
+ $results['scan_tag_inventory'] = $tagData
+ }
+ catch { $errors['scan_tag_inventory'] = $_.Exception.Message }
+ }
+
+ # Run Policy Inventory early (policy recommendations depend on it)
+ $policyData = $null
+ $policyTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-PolicyInventory' }
+ if ($policyTool) {
+ try {
+ $params = @{ Subscriptions = $subs }
+ if ($tenantId) { $params['TenantId'] = $tenantId }
+ $policyData = Get-PolicyInventory @params
+ $results['scan_policy_inventory'] = $policyData
+ }
+ catch { $errors['scan_policy_inventory'] = $_.Exception.Message }
+ }
+
+ # Run remaining modules
+ foreach ($tool in $modulesToRun) {
+ if ($tool.fn -in @('Get-TagInventory', 'Get-PolicyInventory')) { continue }
+
+ try {
+ $fn = $tool.fn
+
+ # Scalable Kusto path: serve cost-family modules from the engine
+ # (summaries only). Preferred over the row-reader; on a provider
+ # error, fall through to Raw / export / live API below.
+ if ($kustoProvider -and $fn -in $costFns) {
+ $kr = switch ($fn) {
+ 'Get-CostData' { Get-FOHubCostSummary -Provider $kustoProvider }
+ 'Get-ResourceCosts' { Get-FOHubResourceCosts -Provider $kustoProvider }
+ 'Get-CostByTag' { Get-FOHubCostByTag -Provider $kustoProvider }
+ }
+ if (-not ($kr -is [System.Collections.IDictionary] -and $kr.Contains('Error') -and $kr.Error)) {
+ $results[$tool.name] = $kr
+ $hubServed[$tool.name] = $true
+ continue
+ }
+ }
+
+ # Export-first: serve cost-family modules from the hub when usable
+ if ($hubUsable -and $fn -in $costFns) {
+ $results[$tool.name] = switch ($fn) {
+ 'Get-CostData' { ConvertTo-CostDataFromHub -HubData $hubInfo.Raw }
+ 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromHub -HubData $hubInfo.Raw }
+ 'Get-CostByTag' {
+ $tagInv = ConvertTo-TagInventoryFromHub -HubData $hubInfo.Raw
+ $existingTags = if ($tagInv.TagNames) { $tagInv.TagNames } else { @{} }
+ ConvertTo-CostByTagFromHub -HubData $hubInfo.Raw -ExistingTags $existingTags
+ }
+ }
+ $hubServed[$tool.name] = $true
+ continue
+ }
+
+ # Export-first: serve cost-family modules from a generic CSV export
+ if ($exportUsable -and $fn -in $costFns) {
+ $exp = $hubInfo.ExportData
+ $results[$tool.name] = switch ($fn) {
+ 'Get-CostData' { ConvertTo-CostDataFromExport -ExportData $exp -Subscriptions $subs }
+ 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromExport -ExportData $exp -Subscriptions $subs }
+ 'Get-CostByTag' { ConvertTo-CostByTagFromExport -ExportData $exp }
+ }
+ $hubServed[$tool.name] = $true
+ continue
+ }
+
+ $cmdInfo = Get-Command $fn -ErrorAction Stop
+ $params = @{}
+
+ if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) { $params['Subscriptions'] = $subs }
+ if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) { $params['TenantId'] = $tenantId }
+
+ # Inject dependencies
+ if ($tool.requiresTagInventory -and $tagData) {
+ if ($fn -eq 'Get-TagRecommendations') {
+ $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } }
+ if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations }
+ }
+ elseif ($fn -eq 'Get-CostByTag') {
+ $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} }
+ }
+ }
+ if ($tool.requiresPolicyInventory -and $policyData) {
+ $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } }
+ }
+ if ($tool.requiresBudgets) {
+ $budgetResult = $results['scan_budget_status']
+ $budgetRows = if ($budgetResult -and $budgetResult.Budgets) { @($budgetResult.Budgets) } else { @() }
+ if ($budgetRows.Count -eq 0) {
+ $results[$tool.name] = @()
+ continue
+ }
+ $params = @{ Budgets = $budgetRows }
+ }
+
+ $results[$tool.name] = & $fn @params
+ }
+ catch {
+ $errors[$tool.name] = $_.Exception.Message
+ }
+ }
+
+ return @{
+ tool = 'run_full_scan'
+ subscriptions = @($subs | ForEach-Object { @{ id = $_.Id; name = $_.Name } })
+ results = $results
+ errors = $errors
+ modulesRun = $modulesToRun.Count
+ costDataSource = if ($kustoProvider) { 'FinOpsHubKusto' } elseif ($hubUsable) { 'FinOpsHub' } elseif ($exportUsable) { 'CostManagementExport' } else { 'LiveApi' }
+ hubAsOf = if ($hubUsable -or $exportUsable) { $hubInfo.Decision.Freshness } else { $null }
+ hubCoveragePct = if ($hubUsable -or $exportUsable) { $hubInfo.Decision.CoveragePct } else { $null }
+ hubServedModules = @($hubServed.Keys)
+ timestamp = (Get-Date -Format 'o')
+ }
+}
+
+# =====================================================================
+# JSON-RPC MESSAGE HANDLING
+# =====================================================================
+function Send-JsonRpc {
+ param([object]$Message)
+ $json = $Message | ConvertTo-Json -Depth 20 -Compress
+ [Console]::Out.WriteLine($json)
+ [Console]::Out.Flush()
+}
+
+function Send-Result {
+ param([object]$Id, [object]$Result)
+ Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; result = $Result }
+}
+
+function Send-Error {
+ param([object]$Id, [int]$Code, [string]$Message)
+ Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; error = @{ code = $Code; message = $Message } }
+}
+
+function Handle-Initialize {
+ param([object]$Id)
+ Send-Result -Id $Id -Result @{
+ protocolVersion = $MCP_VERSION
+ capabilities = @{
+ tools = @{ listChanged = $false }
+ resources = @{ subscribe = $false; listChanged = $false }
+ }
+ serverInfo = @{
+ name = $SERVER_NAME
+ version = $SERVER_VERSION
+ }
+ instructions = 'TENANT SAFETY: This server scans whatever Azure session it holds, which it caches at startup and which may differ from the tenant the user expects. ALWAYS call get_azure_context at the start of a session and surface the active account/tenant/subscription to the user before running any scan or remediation. Every tool result also carries an azureContext block — relay its tenant/subscription to the user. If the context is wrong, the user must switch (Set-AzContext / Connect-AzAccount -TenantId) and RESTART this server.'
+ }
+}
+
+function Handle-ToolsList {
+ param([object]$Id)
+ $tools = $toolDefinitions | ForEach-Object {
+ @{
+ name = $_.name
+ description = $_.description
+ inputSchema = $_.inputSchema
+ }
+ }
+ Send-Result -Id $Id -Result @{ tools = @($tools) }
+}
+
+function Handle-ToolsCall {
+ param([object]$Id, [hashtable]$Params)
+ $toolName = $Params.name
+ $arguments = if ($Params.arguments) { $Params.arguments } else { @{} }
+
+ try {
+ # Redirect non-error streams (warning/verbose/debug/information) to
+ # $null so nothing from module execution leaks onto stdout. The
+ # function's return value (stream 1) still flows into $result.
+ $result = Invoke-McpTool -ToolName $toolName -Arguments $arguments 3>$null 4>$null 5>$null 6>$null
+ # Attach FinOps KPI correlations (additive; no-op for tools with no mapping)
+ try { $result = Add-KpiInsights -Result $result } catch { }
+ # Attach the active Azure context to EVERY result so the caller can
+ # always see which tenant/subscription the scan actually ran against
+ # and never acts on data from the wrong tenant by accident.
+ try {
+ if ($result -is [System.Collections.IDictionary]) {
+ $result['azureContext'] = Get-AzContextSummary
+ }
+ }
+ catch { }
+ $json = $result | ConvertTo-Json -Depth 20 -Compress
+ Send-Result -Id $Id -Result @{
+ content = @(
+ @{ type = 'text'; text = $json }
+ )
+ }
+ }
+ catch {
+ $errMsg = $_.Exception.Message
+ # Include permission hint if available
+ $toolDef = $toolDefinitions | Where-Object { $_.name -eq $toolName }
+ if ($toolDef -and $permissionMap.ContainsKey($toolDef.fn)) {
+ $perm = $permissionMap[$toolDef.fn]
+ $errMsg += " | Required: $($perm.role) at $($perm.scope) scope ($($perm.api))"
+ }
+ Send-Result -Id $Id -Result @{
+ content = @(
+ @{ type = 'text'; text = $errMsg }
+ )
+ isError = $true
+ }
+ }
+}
+
+function Handle-ResourcesList {
+ param([object]$Id)
+ $resources = $resourceDefinitions | ForEach-Object {
+ @{
+ uri = $_.uri
+ name = $_.name
+ description = $_.description
+ mimeType = $_.mimeType
+ }
+ }
+ Send-Result -Id $Id -Result @{ resources = @($resources) }
+}
+
+function Handle-ResourcesRead {
+ param([object]$Id, [hashtable]$Params)
+ $uri = $Params.uri
+
+ switch ($uri) {
+ 'finops://permissions' {
+ $content = $permissionMap | ConvertTo-Json -Depth 5
+ Send-Result -Id $Id -Result @{
+ contents = @(
+ @{ uri = $uri; mimeType = 'application/json'; text = $content }
+ )
+ }
+ }
+ 'finops://modules' {
+ $modules = $toolDefinitions | Where-Object { $_.fn -ne '_full_scan' } | ForEach-Object {
+ @{ name = $_.name; description = $_.description; category = $_.category; function = $_.fn }
+ }
+ $content = $modules | ConvertTo-Json -Depth 5
+ Send-Result -Id $Id -Result @{
+ contents = @(
+ @{ uri = $uri; mimeType = 'application/json'; text = $content }
+ )
+ }
+ }
+ default {
+ Send-Error -Id $Id -Code -32602 -Message "Unknown resource URI: $uri"
+ }
+ }
+}
+
+# =====================================================================
+# MAIN LOOP — Read JSON-RPC from stdin, dispatch, respond
+# =====================================================================
+[Console]::Error.WriteLine("FinOps Multitool MCP Server v$SERVER_VERSION starting...")
+
+# Suppress Write-Host by redirecting the Information stream
+$origInfoPref = $InformationPreference
+$InformationPreference = 'SilentlyContinue'
+
+try {
+ while ($true) {
+ $line = [Console]::In.ReadLine()
+ if ($null -eq $line) { break } # stdin closed
+ $line = $line.Trim()
+ if ($line -eq '') { continue }
+
+ try {
+ $msg = $line | ConvertFrom-Json -AsHashtable -ErrorAction Stop
+ }
+ catch {
+ # Skip malformed JSON
+ [Console]::Error.WriteLine("Malformed JSON-RPC: $line")
+ continue
+ }
+
+ $method = $msg.method
+ $id = $msg.id
+ $params = if ($msg.params) { $msg.params } else { @{} }
+
+ # Dispatch inside its own try/catch so a single bad request (e.g. a
+ # handler throwing) can NEVER abandon the read loop and exit the
+ # process. Requests (id present) get a JSON-RPC internal error;
+ # notifications (no id) are swallowed.
+ try {
+ switch ($method) {
+ 'initialize' { Handle-Initialize -Id $id }
+ 'initialized' { <# notification, no response #> }
+ 'tools/list' { Handle-ToolsList -Id $id }
+ 'tools/call' { Handle-ToolsCall -Id $id -Params $params }
+ 'resources/list' { Handle-ResourcesList -Id $id }
+ 'resources/read' { Handle-ResourcesRead -Id $id -Params $params }
+ 'notifications/initialized' { <# notification, no response #> }
+ 'ping' { Send-Result -Id $id -Result @{} }
+ default {
+ if ($null -ne $id) {
+ Send-Error -Id $id -Code -32601 -Message "Method not found: $method"
+ }
+ }
+ }
+ }
+ catch {
+ [Console]::Error.WriteLine("Handler error for method '$method': $($_.Exception.Message)")
+ if ($null -ne $id) {
+ try { Send-Error -Id $id -Code -32603 -Message "Internal error handling '$method': $($_.Exception.Message)" }
+ catch { [Console]::Error.WriteLine("Failed to send error response: $($_.Exception.Message)") }
+ }
+ }
+ }
+}
+finally {
+ $InformationPreference = $origInfoPref
+ [Console]::Error.WriteLine("FinOps Multitool MCP Server stopped.")
+}
diff --git a/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1
new file mode 100644
index 000000000..30df77750
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1
@@ -0,0 +1,226 @@
+###########################################################################
+# TEST-MCPSERVER.PS1
+# FINOPS MULTITOOL MCP SERVER TEST HARNESS
+###########################################################################
+# Purpose: End-to-end smoke + integration test for Start-McpServer.ps1.
+# Spawns the server as a child process, drives the JSON-RPC
+# lifecycle over stdio, and asserts on protocol, tools,
+# resources, a live Azure tool call, and error/edge paths.
+# Author: Zac Larsen
+# Date: Created for FinOps Toolkit MCP integration testing
+#
+# Description:
+# 1. Starts Start-McpServer.ps1 with redirected stdin/stdout/stderr
+# 2. Sends initialize / tools/list / resources/* requests and asserts
+# 3. Runs a live tools/call against Azure (requires Connect-AzAccount)
+# 4. Verifies error handling (unknown tool, bad resource, malformed JSON)
+# 5. Prints a pass/fail summary and exits non-zero on any failure
+#
+# ── Parameters ──────────────────────────────────────────────────
+# ServerPath Path to Start-McpServer.ps1 (default: sibling file)
+# LiveTool Tool name to call live (default: scan_orphaned_resources)
+# SubscriptionId Optional subscription to scope the live call
+# SkipLive Skip the live Azure tool call (protocol-only run)
+# TimeoutSeconds Per-request response timeout (default: 120)
+#
+# Prerequisites:
+# - PowerShell 7+ (pwsh)
+# - Active Azure session (Connect-AzAccount) unless -SkipLive
+#
+# Usage: .\Test-McpServer.ps1
+# .\Test-McpServer.ps1 -SkipLive
+# .\Test-McpServer.ps1 -LiveTool scan_tag_inventory -SubscriptionId
+###########################################################################
+
+[CmdletBinding()]
+param(
+ [string]$ServerPath = (Join-Path $PSScriptRoot 'Start-McpServer.ps1'),
+ [string]$LiveTool = 'scan_orphaned_resources',
+ [string]$SubscriptionId,
+ [switch]$SkipLive,
+ [int]$TimeoutSeconds = 120
+)
+
+$ErrorActionPreference = 'Stop'
+
+$script:Pass = 0
+$script:Fail = 0
+$script:Failures = @()
+
+function Write-TestResult {
+ param([string]$Name, [bool]$Ok, [string]$Detail)
+ if ($Ok) {
+ $script:Pass++
+ Write-Host " [PASS] $Name" -ForegroundColor Green
+ }
+ else {
+ $script:Fail++
+ $script:Failures += $Name
+ Write-Host " [FAIL] $Name" -ForegroundColor Red
+ if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkYellow }
+ }
+}
+
+function Invoke-Rpc {
+ param(
+ [System.Diagnostics.Process]$Proc,
+ [string]$Json,
+ [int]$Timeout = $TimeoutSeconds,
+ [switch]$NoResponse
+ )
+ $Proc.StandardInput.WriteLine($Json)
+ $Proc.StandardInput.Flush()
+ if ($NoResponse) { return $null }
+
+ $task = $Proc.StandardOutput.ReadLineAsync()
+ if (-not $task.Wait([TimeSpan]::FromSeconds($Timeout))) {
+ throw "Timed out waiting for response after ${Timeout}s"
+ }
+ $line = $task.Result
+ if ($null -eq $line) { throw 'Server closed stdout unexpectedly' }
+ return ($line | ConvertFrom-Json)
+}
+
+# =====================================================================
+# PRE-FLIGHT
+# =====================================================================
+Write-Host "FinOps Multitool MCP Server — Test Harness" -ForegroundColor Cyan
+Write-Host ("=" * 60)
+
+if (-not (Test-Path $ServerPath)) {
+ Write-Host "Server script not found: $ServerPath" -ForegroundColor Red
+ exit 2
+}
+
+if (-not $SkipLive) {
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+ if (-not $ctx) {
+ Write-Host "No active Azure context — live tool call will be skipped. Run Connect-AzAccount or pass -SkipLive." -ForegroundColor Yellow
+ $SkipLive = $true
+ }
+ else {
+ Write-Host "Azure context: $($ctx.Account.Id) | $($ctx.Subscription.Name)" -ForegroundColor DarkGray
+ }
+}
+
+# =====================================================================
+# START SERVER PROCESS
+# =====================================================================
+$psi = [System.Diagnostics.ProcessStartInfo]::new()
+$psi.FileName = 'pwsh'
+$psi.ArgumentList.Add('-NoProfile')
+$psi.ArgumentList.Add('-File')
+$psi.ArgumentList.Add($ServerPath)
+$psi.RedirectStandardInput = $true
+$psi.RedirectStandardOutput = $true
+$psi.RedirectStandardError = $true
+$psi.UseShellExecute = $false
+
+$proc = [System.Diagnostics.Process]::Start($psi)
+Start-Sleep -Milliseconds 500
+
+try {
+ # -----------------------------------------------------------------
+ # 1. initialize
+ # -----------------------------------------------------------------
+ Write-Host "`nProtocol lifecycle" -ForegroundColor Cyan
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}'
+ Write-TestResult 'initialize returns serverInfo.name = finops-multitool' ($r.result.serverInfo.name -eq 'finops-multitool') "got: $($r.result.serverInfo.name)"
+ Write-TestResult 'initialize returns protocolVersion 2024-11-05' ($r.result.protocolVersion -eq '2024-11-05') "got: $($r.result.protocolVersion)"
+
+ # initialized notification (no response expected)
+ Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","method":"notifications/initialized"}' -NoResponse | Out-Null
+
+ # -----------------------------------------------------------------
+ # 2. tools/list
+ # -----------------------------------------------------------------
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
+ $toolCount = @($r.result.tools).Count
+ Write-TestResult "tools/list returns 40 tools" ($toolCount -eq 40) "got: $toolCount"
+ $hasSchema = @($r.result.tools | Where-Object { $_.inputSchema.type -eq 'object' }).Count -eq $toolCount
+ Write-TestResult 'every tool has an object inputSchema' $hasSchema
+
+ # -----------------------------------------------------------------
+ # 3. resources/list + resources/read
+ # -----------------------------------------------------------------
+ Write-Host "`nResources" -ForegroundColor Cyan
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":3,"method":"resources/list"}'
+ Write-TestResult 'resources/list returns 2 resources' (@($r.result.resources).Count -eq 2) "got: $(@($r.result.resources).Count)"
+
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"finops://permissions"}}'
+ $permOk = $false
+ try { $null = $r.result.contents[0].text | ConvertFrom-Json; $permOk = $true } catch {}
+ Write-TestResult 'resources/read finops://permissions returns valid JSON' $permOk
+
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"finops://modules"}}'
+ $modOk = $false
+ try { $null = $r.result.contents[0].text | ConvertFrom-Json; $modOk = $true } catch {}
+ Write-TestResult 'resources/read finops://modules returns valid JSON' $modOk
+
+ # -----------------------------------------------------------------
+ # 4. error / edge paths
+ # -----------------------------------------------------------------
+ Write-Host "`nError handling" -ForegroundColor Cyan
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"finops://does-not-exist"}}'
+ Write-TestResult 'unknown resource URI returns JSON-RPC error -32602' ($r.error.code -eq -32602) "got: $($r.error.code)"
+
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":7,"method":"no/such/method"}'
+ Write-TestResult 'unknown method returns JSON-RPC error -32601' ($r.error.code -eq -32601) "got: $($r.error.code)"
+
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"definitely_not_a_tool","arguments":{}}}'
+ Write-TestResult 'unknown tool call returns isError result' ($r.result.isError -eq $true)
+
+ # malformed JSON should be skipped; server must survive and answer the next ping
+ Invoke-Rpc -Proc $proc -Json '{ this is not valid json' -NoResponse | Out-Null
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":9,"method":"ping"}'
+ Write-TestResult 'server survives malformed JSON and answers ping' ($null -ne $r.result)
+
+ # spec-legal STRING id must be echoed back verbatim (not coerced to int) and
+ # must not crash the read loop. Regression guard for the [int]$Id id bug.
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":"abc-123","method":"ping"}'
+ Write-TestResult 'string JSON-RPC id is echoed back verbatim' ($r.id -eq 'abc-123') "got: $($r.id)"
+ $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":11,"method":"ping"}'
+ Write-TestResult 'server survives a string-id request and answers the next ping' ($null -ne $r.result)
+
+ # -----------------------------------------------------------------
+ # 5. live tool call (requires Azure)
+ # -----------------------------------------------------------------
+ Write-Host "`nLive Azure tool call" -ForegroundColor Cyan
+ if ($SkipLive) {
+ Write-Host " [SKIP] live tool call ($LiveTool) — no Azure context or -SkipLive set" -ForegroundColor Yellow
+ }
+ else {
+ $argJson = if ($SubscriptionId) { "{`"subscriptionId`":`"$SubscriptionId`"}" } else { '{}' }
+ $callJson = "{`"jsonrpc`":`"2.0`",`"id`":10,`"method`":`"tools/call`",`"params`":{`"name`":`"$LiveTool`",`"arguments`":$argJson}}"
+ Write-Host " calling $LiveTool ..." -ForegroundColor DarkGray
+ $r = Invoke-Rpc -Proc $proc -Json $callJson
+ $isError = $r.result.isError -eq $true
+ if ($isError) {
+ Write-TestResult "live $LiveTool executed without error" $false $r.result.content[0].text
+ }
+ else {
+ $payload = $null
+ try { $payload = $r.result.content[0].text | ConvertFrom-Json } catch {}
+ Write-TestResult "live $LiveTool returns parseable content" ($null -ne $payload)
+ Write-TestResult "live $LiveTool result echoes tool name" ($payload.tool -eq $LiveTool) "got: $($payload.tool)"
+ }
+ }
+}
+finally {
+ # Close stdin so the server's read loop exits cleanly
+ try { $proc.StandardInput.Close() } catch {}
+ if (-not $proc.WaitForExit(5000)) { try { $proc.Kill() } catch {} }
+ $proc.Dispose()
+}
+
+# =====================================================================
+# SUMMARY
+# =====================================================================
+Write-Host "`n$('=' * 60)"
+Write-Host "Results: $script:Pass passed, $script:Fail failed" -ForegroundColor ($(if ($script:Fail -eq 0) { 'Green' } else { 'Red' }))
+if ($script:Fail -gt 0) {
+ Write-Host "Failed tests:" -ForegroundColor Red
+ $script:Failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+ exit 1
+}
+exit 0
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..5127d054d
--- /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 this MCP server can inform from scan output. 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/Deploy-PolicyAssignment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1
new file mode 100644
index 000000000..a3481d94d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1
@@ -0,0 +1,212 @@
+###########################################################################
+# DEPLOY-POLICYASSIGNMENT.PS1
+# AZURE FINOPS MULTITOOL - Deploy Azure Policy Assignments
+###########################################################################
+# Purpose: Create a policy assignment at a given scope (management group,
+# subscription, or resource group) for a built-in policy
+# definition with a user-selected effect.
+#
+# Uses ARM REST API PUT to create policy assignments.
+###########################################################################
+
+function Deploy-PolicyAssignment {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope, # /subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy
+
+ [Parameter(Mandatory)]
+ [string]$PolicyDefinitionId, # Full built-in policy def resource ID
+
+ [Parameter(Mandatory)]
+ [string]$Effect, # Audit, Deny, Disabled, etc.
+
+ [string]$DisplayName = '',
+
+ [hashtable]$AdditionalParameters = @{}
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+ if ($Effect -notin @('Audit','Deny','Disabled','AuditIfNotExists','DeployIfNotExists','Modify','Append')) {
+ throw "Invalid effect: $Effect. Must be one of: Audit, Deny, Disabled, AuditIfNotExists, DeployIfNotExists, Modify, Append"
+ }
+ $isInitiative = $PolicyDefinitionId -match '/policySetDefinitions/'
+ if ($PolicyDefinitionId -notmatch '^/providers/Microsoft\.Authorization/policy(Set)?Definitions/') {
+ throw "Invalid policy definition ID format."
+ }
+
+ # Generate a unique assignment name (max 128 chars, alphanumeric + hyphens)
+ $defGuid = ($PolicyDefinitionId -split '/')[-1]
+ $scopeHash = [System.BitConverter]::ToString(
+ [System.Security.Cryptography.SHA256]::Create().ComputeHash(
+ [System.Text.Encoding]::UTF8.GetBytes($Scope)
+ )
+ ).Replace('-','').Substring(0,8).ToLower()
+ $assignName = "finops-$scopeHash-$defGuid"
+ if ($assignName.Length -gt 128) { $assignName = $assignName.Substring(0, 128) }
+
+ $assignDisplayName = if ($DisplayName) { "FinOps: $DisplayName" } else { "FinOps Policy Assignment" }
+
+ Write-Host " Deploying policy assignment '$assignDisplayName' to scope: $Scope" -ForegroundColor Cyan
+ Write-Host " Effect: $Effect | Definition: $defGuid" -ForegroundColor Cyan
+
+ # Query the policy definition to discover which parameters it actually accepts
+ $validParamNames = @()
+ try {
+ $defPath = "$($PolicyDefinitionId)?api-version=2021-06-01"
+ $defResp = Invoke-AzRestMethodWithRetry -Path $defPath -Method GET
+ if ($defResp.StatusCode -eq 200) {
+ $defObj = $defResp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($defObj.properties.parameters) {
+ $validParamNames = @($defObj.properties.parameters.PSObject.Properties.Name)
+ Write-Host " Valid parameters: $($validParamNames -join ', ')" -ForegroundColor Gray
+ }
+ }
+ } catch {
+ Write-Host " Could not query policy definition parameters, sending all." -ForegroundColor Yellow
+ }
+
+ # Build parameters - only include params the definition accepts
+ $policyParams = @{}
+ # Include effect only if the definition has an effect parameter
+ if ($validParamNames.Count -eq 0 -or $validParamNames -contains 'effect') {
+ $policyParams['effect'] = @{ value = $Effect }
+ }
+ foreach ($key in $AdditionalParameters.Keys) {
+ if ($validParamNames.Count -eq 0 -or $validParamNames -contains $key) {
+ $policyParams[$key] = @{ value = $AdditionalParameters[$key] }
+ } else {
+ Write-Host " Skipping parameter '$key' - not defined in policy definition." -ForegroundColor Yellow
+ }
+ }
+
+ $body = @{
+ properties = @{
+ displayName = $assignDisplayName
+ description = "Deployed by Azure FinOps Multitool"
+ policyDefinitionId = $PolicyDefinitionId
+ parameters = $policyParams
+ enforcementMode = 'Default'
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $assignPath = "$Scope/providers/Microsoft.Authorization/policyAssignments/$($assignName)?api-version=2022-06-01"
+
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path $assignPath -Method PUT -Payload $body
+ if ($response.StatusCode -in @(200, 201)) {
+ Write-Host " Policy assignment created successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Policy '$assignDisplayName' assigned with effect '$Effect' to $Scope"
+ StatusCode = $response.StatusCode
+ AssignmentName = $assignName
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Policy assignment failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $response.StatusCode
+ }
+ }
+ } catch {
+ Write-Warning " Policy assignment error: $($_.Exception.Message)"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $_.Exception.Message
+ StatusCode = 0
+ }
+ }
+}
+
+function Remove-PolicyAssignment {
+ <#
+ .SYNOPSIS
+ Deletes a policy assignment by its full ARM assignment ID.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$AssignmentId # Full ARM resource ID of the assignment
+ )
+
+ Write-Host " Removing policy assignment: $AssignmentId" -ForegroundColor Cyan
+
+ $deletePath = "$($AssignmentId)?api-version=2022-06-01"
+
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path $deletePath -Method DELETE
+ if ($response.StatusCode -in @(200, 204)) {
+ Write-Host " Policy assignment removed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Policy assignment removed"
+ StatusCode = $response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Policy removal failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $response.StatusCode
+ }
+ }
+ } catch {
+ Write-Warning " Policy removal error: $($_.Exception.Message)"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $_.Exception.Message
+ StatusCode = 0
+ }
+ }
+}
+
+function Get-PolicyScopes {
+ <#
+ .SYNOPSIS
+ Returns available scopes (subscriptions + resource groups) for policy assignment.
+ Identical pattern to Get-TagScopes but for policy deployment.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $scopes = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ foreach ($sub in $Subscriptions) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = "[Sub] $($sub.Name)"
+ Scope = "/subscriptions/$($sub.Id)"
+ Type = 'Subscription'
+ })
+
+ try {
+ $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $rgs = ($resp.Content | ConvertFrom-Json).value
+ foreach ($rg in $rgs) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = " [RG] $($sub.Name) / $($rg.name)"
+ Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)"
+ Type = 'ResourceGroup'
+ })
+ }
+ }
+ } catch {
+ Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ return $scopes
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1
new file mode 100644
index 000000000..df5d9aa6d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1
@@ -0,0 +1,217 @@
+###########################################################################
+# DEPLOY-RESOURCETAG.PS1
+# AZURE FINOPS MULTITOOL - Deploy Tags to Azure Resources
+###########################################################################
+# Purpose: Apply a tag (name + value) to a subscription, resource group,
+# or individual resource via ARM REST API (PATCH merge).
+# Preserves existing tags -- only adds or updates the target tag.
+###########################################################################
+
+function Deploy-ResourceTag {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope, # Full ARM resource ID (/subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy or full resource ID)
+
+ [Parameter(Mandatory)]
+ [string]$TagName,
+
+ [Parameter(Mandatory)]
+ [string]$TagValue
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+ if ($TagName -match '[<>&''"\\]') {
+ throw "Tag name contains invalid characters."
+ }
+ if ($TagValue -match '[<>&''"]' -and $TagValue.Length -gt 256) {
+ throw "Tag value exceeds 256 characters."
+ }
+
+ Write-Host " Deploying tag '$TagName=$TagValue' to scope: $Scope" -ForegroundColor Cyan
+
+ # Use the Tags API to merge (preserves existing tags)
+ $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+
+ $body = @{
+ operation = 'Merge'
+ properties = @{
+ tags = @{
+ $TagName = $TagValue
+ }
+ }
+ } | ConvertTo-Json -Depth 5
+
+ # Use Invoke-WebRequest with timeout to prevent indefinite hanging
+ # (Invoke-AzRestMethod has no timeout parameter)
+ $token = Get-PlainAccessToken
+ $headers = @{
+ 'Authorization' = "Bearer $token"
+ 'Content-Type' = 'application/json'
+ }
+
+ try {
+ $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ if ([int]$response.StatusCode -in @(200, 201)) {
+ Write-Host " Tag deployed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Tag '$TagName=$TagValue' applied to $Scope"
+ StatusCode = [int]$response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Tag deployment failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = [int]$response.StatusCode
+ }
+ }
+ } catch {
+ $errMsg = $_.Exception.Message
+ $statusCode = 0
+ # Extract error details from HTTP error responses
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ try {
+ $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ } catch {}
+ }
+ $safeMsg = $errMsg -replace 'Bearer [^\s]+', 'Bearer ***REDACTED***'
+ Write-Warning " Tag deployment failed: $safeMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $safeMsg
+ StatusCode = $statusCode
+ }
+ }
+}
+
+function Remove-ResourceTag {
+ <#
+ .SYNOPSIS
+ Removes a tag from a subscription or resource group via ARM Tags API (DELETE operation).
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope,
+
+ [Parameter(Mandatory)]
+ [string]$TagName
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+
+ Write-Host " Removing tag '$TagName' from scope: $Scope" -ForegroundColor Cyan
+
+ $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+
+ $body = @{
+ operation = 'Delete'
+ properties = @{
+ tags = @{
+ $TagName = ''
+ }
+ }
+ } | ConvertTo-Json -Depth 5
+
+ $token = Get-PlainAccessToken
+ $headers = @{
+ 'Authorization' = "Bearer $token"
+ 'Content-Type' = 'application/json'
+ }
+
+ try {
+ $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ if ([int]$response.StatusCode -in @(200, 201)) {
+ Write-Host " Tag removed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Tag '$TagName' removed from $Scope"
+ StatusCode = [int]$response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = [int]$response.StatusCode
+ }
+ }
+ } catch {
+ $errMsg = $_.Exception.Message
+ $statusCode = 0
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ try {
+ $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ } catch {}
+ }
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $statusCode
+ }
+ }
+}
+
+function Get-TagScopes {
+ <#
+ .SYNOPSIS
+ Returns available scopes (subscriptions + resource groups) for tag deployment.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $scopes = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ foreach ($sub in $Subscriptions) {
+ # Add subscription itself
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = "[Sub] $($sub.Name)"
+ Scope = "/subscriptions/$($sub.Id)"
+ Type = 'Subscription'
+ })
+
+ # Get resource groups
+ try {
+ $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $rgs = ($resp.Content | ConvertFrom-Json).value
+ foreach ($rg in $rgs) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = " [RG] $($sub.Name) / $($rg.name)"
+ Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)"
+ Type = 'ResourceGroup'
+ })
+ }
+ }
+ } catch {
+ Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ return $scopes
+}
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..0f3f44287
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1
@@ -0,0 +1,176 @@
+###########################################################################
+# 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 = "https://management.azure.com$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 = "https://management.azure.com$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..ca615e327
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1
@@ -0,0 +1,110 @@
+###########################################################################
+# 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..5b843ca3f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1
@@ -0,0 +1,361 @@
+###########################################################################
+# 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
+ try { $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').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 = "https://management.azure.com$($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..f122055fb
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1
@@ -0,0 +1,86 @@
+###########################################################################
+# 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..3c2504fbf
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1
@@ -0,0 +1,156 @@
+###########################################################################
+# 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..cd88a0ba9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1
@@ -0,0 +1,128 @@
+###########################################################################
+# 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).
+# Author: Zac Larsen
+# 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..da5098263
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1
@@ -0,0 +1,201 @@
+###########################################################################
+# 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..5b1baf423
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1
@@ -0,0 +1,364 @@
+###########################################################################
+# 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) {
+ $budgets = ($resp.Content | ConvertFrom-Json).value
+ if ($budgets -and $budgets.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..a26c29d51
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1
@@ -0,0 +1,221 @@
+###########################################################################
+# 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..a123c58d6
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1
@@ -0,0 +1,306 @@
+###########################################################################
+# 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..e869e3f5f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1
@@ -0,0 +1,155 @@
+###########################################################################
+# 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..7b13837d9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1
@@ -0,0 +1,515 @@
+###########################################################################
+# 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,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [switch]$RestrictToSelected
+ )
+
+ # 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..a290f339c
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1
@@ -0,0 +1,355 @@
+###########################################################################
+# 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)
+
+ if ($result.properties.rows) {
+ foreach ($row in $result.properties.rows) {
+ $subId = $row[1]
+ $amount = [math]::Round($row[0], 2)
+ $currency = $row[2]
+
+ 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..8b30bccb9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1
@@ -0,0 +1,304 @@
+###########################################################################
+# 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..a34c6e03b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1
@@ -0,0 +1,161 @@
+###########################################################################
+# 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 -------------------
+ $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').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 = "https://management.azure.com$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..7855ad3e6
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1
@@ -0,0 +1,182 @@
+###########################################################################
+# 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..d42ef081d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1
@@ -0,0 +1,153 @@
+###########################################################################
+# 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..21865787b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1
@@ -0,0 +1,173 @@
+###########################################################################
+# 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..761034ee3
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1
@@ -0,0 +1,216 @@
+###########################################################################
+# 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..05c0d05a7
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1
@@ -0,0 +1,285 @@
+###########################################################################
+# 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
+ PolicyCount = 0
+ }
+ }
+ $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..d615843d9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1
@@ -0,0 +1,251 @@
+###########################################################################
+# 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-fcbef5-37f7'
+ 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..3ca656525
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1
@@ -0,0 +1,230 @@
+###########################################################################
+# 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..6bacca3ca
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1
@@ -0,0 +1,366 @@
+###########################################################################
+# 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 ($i = 0; $i -lt $result.properties.columns.Count; $i++) {
+ $cols[$result.properties.columns[$i].name] = $i
+ }
+
+ $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)
+ $cols = @{}
+ for ($i = 0; $i -lt $result.properties.columns.Count; $i++) {
+ $cols[$result.properties.columns[$i].name] = $i
+ }
+
+ # 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..17db7ba4d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1
@@ -0,0 +1,372 @@
+###########################################################################
+# 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..3f71762e1
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1
@@ -0,0 +1,419 @@
+###########################################################################
+# 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 MCP dispatcher (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 { "'$_'" }) -join ','
+ $clauses += "id in~ ($idList)"
+ }
+ if ($ResourceGroup) {
+ $clauses += "resourceGroup =~ '$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 { "'$_'" }) -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..25bf6faa9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1
@@ -0,0 +1,123 @@
+###########################################################################
+# 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 ---------------
+ $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').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 = "https://management.azure.com$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 = "https://management.azure.com$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..2f9849a77
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1
@@ -0,0 +1,240 @@
+###########################################################################
+# 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..54e776c99
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1
@@ -0,0 +1,170 @@
+###########################################################################
+# 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..c536c6ede
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1
@@ -0,0 +1,123 @@
+###########################################################################
+# 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..e70c00396
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1
@@ -0,0 +1,378 @@
+###########################################################################
+# 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..085e574eb
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1
@@ -0,0 +1,307 @@
+###########################################################################
+# 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.
+# Author: Zac Larsen
+# 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..75e71cd88
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1
@@ -0,0 +1,355 @@
+###########################################################################
+# 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 MCP dispatcher (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 =~ '$ResourceId'"
+ }
+ elseif ($ResourceGroup) {
+ "name =~ '$VmName' and resourceGroup =~ '$ResourceGroup'"
+ }
+ else {
+ "name =~ '$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 { "'$_'" }) -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..8bbad1183
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1
@@ -0,0 +1,256 @@
+###########################################################################
+# 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..6f64ec5bd
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1
@@ -0,0 +1,466 @@
+###########################################################################
+# NEW-POWERBITEMPLATE.PS1
+# POWER BI TEMPLATE GENERATOR (GUI-FREE)
+###########################################################################
+# Purpose: Build a Power BI template (.pbit) from FinOps scan data.
+# Author: Zac Larsen
+# Date: Created for FinOps Toolkit integration
+#
+# Description:
+# Headless, dependency-free generator used by the TUI and the MCP
+# 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. The MCP server calls 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..ea438426e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1
@@ -0,0 +1,271 @@
+###########################################################################
+# 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 {}
+ $props = if ($resObj) { $resObj.properties } else { $null }
+ $location = if ($resObj) { $resObj.location } else { $null }
+
+ # ---- 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 = "https://management.azure.com$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 = "https://management.azure.com$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..a7650a31c
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1
@@ -0,0 +1,317 @@
+###########################################################################
+# 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.
+# Author: Zac Larsen
+# 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 = "https://management.azure.com$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 = "https://management.azure.com$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..0fa45329f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1
@@ -0,0 +1,154 @@
+###########################################################################
+# 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 = "https://management.azure.com$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 = "https://management.azure.com$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..3d9b416f6
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1
@@ -0,0 +1,297 @@
+###########################################################################
+# 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..fb0236c93
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1
@@ -0,0 +1,910 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# GET-COSTEXPORT.PS1
+# COST MANAGEMENT EXPORT DETECTION & FAST READ (TUI / MCP)
+###########################################################################
+# Purpose: Detect existing Cost Management exports (any export, not just a
+# FinOps Hub) and read their CSV data from blob storage so the MCP
+# server can serve cost tools from a pre-materialized export
+# instead of the throttle-bound live Cost Management query API.
+# Author: Zac Larsen
+# Date: Created for FinOps Multitool MCP 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..43653f28f
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1
@@ -0,0 +1,345 @@
+# 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.
+# Author: Zac Larsen
+# 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).
+ $keyList = ($keys | Where-Object { $_ } | ForEach-Object { '"' + ($_ -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..0032e4348
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1
@@ -0,0 +1,382 @@
+###########################################################################
+# GET-KPIINSIGHTS.PS1
+# FINOPS KPI CORRELATION LAYER
+###########################################################################
+# Purpose: Map FinOps Multitool scan output to FinOps Foundation KPIs
+# (https://www.finops.org/finops-kpis/) so MCP users who do not
+# know the KPI taxonomy still see which industry KPIs their results
+# inform, with a computed value where the data allows.
+# Author: Zac Larsen
+# Date: Created for KPI skills
+#
+# Description:
+# Additive only. Does not change any scan. After a tool returns, the MCP
+# 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
+# MCP tool name the KPI catalog keys off (sourceTool). Lets the TUI reuse the
+# exact same compute path as the MCP server, 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 an MCP { tool; data } envelope). Wraps the output in the same
+# envelope the MCP server uses 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..6df8e529b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1
@@ -0,0 +1,13 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+function Get-PlainAccessToken {
+ param([string]$ResourceUrl = 'https://management.azure.com')
+ $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..2ec3e9b71
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1
@@ -0,0 +1,178 @@
+# 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 { @{} } }
+ }
+
+ if ($resp.StatusCode -ne 429) { return $resp }
+
+ # Parse Retry-After header or default to exponential backoff
+ $retryAfter = 10
+ if ($resp.Headers -and $resp.Headers['Retry-After']) {
+ $parsed = 0
+ if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) {
+ $retryAfter = [math]::Max($parsed, 5)
+ }
+ }
+ else {
+ $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60)
+ }
+ $friendly = Get-NextThrottleMessage
+ 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..f363467f3
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1
@@ -0,0 +1,127 @@
+# 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.
+# Author: Zac Larsen
+# 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..a4364a820
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1
@@ -0,0 +1,822 @@
+###########################################################################
+# READ-FINOPSHUBDATA.PS1
+# FINOPS HUB STORAGE DATA READER
+###########################################################################
+# Purpose: Read cost data from a FinOps Hub storage account
+# Author: Zac Larsen
+# 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 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 = Join-Path ([System.IO.Path]::GetTempPath()) 'FinOpsMultitool-Parquet'
+ $markerFile = Join-Path $parquetDir '.installed'
+
+ # If all DLLs were previously installed, just load them
+ if (Test-Path $markerFile) {
+ try {
+ Import-ParquetAssemblies -BasePath $parquetDir
+ return $true
+ }
+ catch {
+ # Corrupt install — wipe and redo
+ 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
+
+ # 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
+ }
+ }
+ }
+
+ # Write marker so next session skips the nuget step
+ 'installed' | Set-Content $markerFile
+
+ 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..23fb2ce59
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1
@@ -0,0 +1,524 @@
+###########################################################################
+# 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.
+# Author: Zac Larsen
+# Date: Created for FinOps Multitool MCP server export-first routing
+#
+# Description:
+# Non-interactive detector used by the MCP server and the agent skill.
+# 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 MCP
+ # 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 MCP server 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 MCP 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..79ce8d07a
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1
@@ -0,0 +1,83 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+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
+ 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 (-not $result) { throw $ps.Streams.Error[0].Exception }
+ }
+ }
+ catch {
+ if ($_.Exception.Message -match '429|throttl|Too Many Requests') { $is429 = $true }
+ else { $ps.Dispose(); throw }
+ }
+ }
+ else {
+ $ps.Stop()
+ Write-Warning " Resource Graph query timed out after $($TimeoutSeconds)s"
+ }
+
+ $ps.Dispose()
+
+ if (-not $is429) { return $result }
+
+ # 429 retry wait
+ $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 30)
+ $friendly = if (Get-Command Get-NextThrottleMessage -ErrorAction SilentlyContinue) { Get-NextThrottleMessage } else { 'Fetching numbers......' }
+ 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/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
new file mode 100644
index 000000000..5d4ab7a56
--- /dev/null
+++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
@@ -0,0 +1,51 @@
+# 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'
+ $modules.Count | Should -BeGreaterOrEqual 20
+ }
+ }
+
+ 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..a3b17369d
--- /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 MCP server 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 (`scan_anomaly_alerts`, `scan_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, `scan_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` (`scan_anomaly_alerts`, `scan_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..aeead1ebe
--- /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 `scan_policy_inventory` and `scan_policy_recommendations` from the `finops-multitool` MCP server first to see what's already assigned and where the gaps are, then generate policy here.
+
+## Workflow
+
+1. **Inventory** — `scan_policy_inventory` (existing assignments, scopes, effects, compliance).
+2. **Gap analysis** — `scan_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..3043fecec
--- /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 MCP server.
+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..4a4baa7f6
--- /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 MCP server (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 (`scan_tag_inventory`, `scan_tag_recommendations`, `scan_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 `scan_tag_inventory`.
+2. **Consistency** — no casing or spelling drift in tag keys/values (`CostCenter` vs `costcenter`, `managed_by` vs `managedBy`). Use `scan_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` MCP tools.
+- 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..1206ca0e0
--- /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 MCP server (see .vscode/mcp.json) 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:
+
+- `scan_cost_data` (current month actuals per subscription)
+- `scan_resource_costs` (top resources by cost)
+- `scan_cost_by_tag` (spend by tag key/value)
+
+All other cost-family tools — `scan_cost_trend`, `scan_budget_status`, `scan_anomaly_alerts`, `scan_reservation_advice`, `scan_commitment_utilization`, `scan_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 `detect_cost_data_source` (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
+
+- The `finops-multitool` MCP server must be running (defined in `.vscode/mcp.json`).
+- 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, `detect_cost_data_source` 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..8a75e91d0
--- /dev/null
+++ b/src/templates/agent-skills/finops-multitool/SKILL.md
@@ -0,0 +1,163 @@
+---
+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" using the FinOps Multitool MCP server. Also use it proactively whenever the conversation turns to Azure cost, waste, savings, governance, or FinOps health and a live read-only scan would answer the question.
+license: MIT
+compatibility: Requires the finops-multitool MCP server to be running (see .vscode/mcp.json) and an authenticated Azure session (Connect-AzAccount) with at least Reader access. The scan tools are read-only; four write/remediation tools are dry-run by default, gated by a write-safety policy, and disabled unless FINOPS_WRITE_MODE is set (the server defaults to ReadOnly).
+metadata:
+ author: microsoft
+ version: '1.0'
+---
+
+# FinOps Multitool
+
+The FinOps Multitool MCP server exposes 40 tools that scan a live Azure environment for cost savings, governance gaps, and FinOps health. Thirty-six are read-only analysis tools; four are write/remediation tools - delete an orphaned resource, deallocate an idle VM, enable Azure Hybrid Benefit, and set a cost allocation rule - that are dry-run by default and gated by a configurable write-safety policy. Use it to ground answers about waste, savings, tags, policy, budgets, and commitments in the customer's actual resource state instead of guessing.
+
+The analysis tools query Azure Resource Graph, Cost Management, and Azure Advisor with **Reader** scope and never modify resources. The four write tools (`remediate_delete_orphaned_resource`, `remediate_deallocate_vm`, `remediate_enable_hybrid_benefit`, and `set_cost_allocation_rule`) are the only ones that can change Azure, and only when explicitly applied: they preview by default (`apply=false`), route through a write-safety gate (protected-tag / resource-group / subscription guardrails, estimated-impact and blast-radius caps, and an append-only audit log), and are disabled entirely unless an operator sets `FINOPS_WRITE_MODE` - the server defaults to `ReadOnly`, which blocks all writes. Be proactive: when a user raises a cost, waste, savings, or governance topic, offer to run the matching scan rather than answering abstractly.
+
+## When to use the server
+
+Run a scan whenever the user wants real numbers from their environment. Examples that should trigger a tool call:
+
+- "Where am I wasting money?" → `scan_orphaned_resources`, `scan_idle_vms`, then `run_full_scan` if they want the full picture
+- "Are we using reservations well?" → `scan_commitment_utilization`, `scan_reservation_advice`
+- "What's our tag coverage?" → `scan_tag_inventory`
+- "Break cost down by CostCenter / team / app" → `scan_cost_by_tag` (run `scan_tag_inventory` first)
+- "Run a FinOps assessment" → `run_full_scan`
+
+If the user is only asking a conceptual question ("what is Azure Hybrid Benefit?"), answer directly — don't force a scan.
+
+## Tool routing
+
+Pick the narrowest tool that answers the question. Use `run_full_scan` only for a broad assessment.
+
+| Intent | Tool | Category |
+| ------------------------------------------------------- | ----------------------------- | ------------- |
+| Orphaned disks, NICs, public IPs, NSGs, deallocated VMs | `scan_orphaned_resources` | Optimization |
+| Idle / underutilized VMs (<5% CPU) | `scan_idle_vms` | Optimization |
+| Storage tier optimization (Hot→Cool/Cold/Archive) | `scan_storage_tier_advice` | Optimization |
+| Windows/SQL not using Azure Hybrid Benefit | `scan_ahb_opportunities` | Optimization |
+| Tag coverage, tag names, untagged resources | `scan_tag_inventory` | Governance |
+| Tag quality fixes (CAF gaps, casing, duplicates) | `scan_tag_recommendations` | Governance |
+| Azure Policy assignments + compliance | `scan_policy_inventory` | Governance |
+| Policy coverage gaps + recommended guardrails | `scan_policy_recommendations` | Governance |
+| Decide hub vs live API before a cost scan | `detect_cost_data_source` | Cost Analysis |
+| Current month actual + forecast spend | `scan_cost_data` | Cost Analysis |
+| Top resources by cost | `scan_resource_costs` | Cost Analysis |
+| Cost broken down by tag key/value | `scan_cost_by_tag` | Cost Analysis |
+| Month-over-month cost trend | `scan_cost_trend` | Cost Analysis |
+| Reservation purchase recommendations | `scan_reservation_advice` | Commitments |
+| Reservation / savings plan utilization | `scan_commitment_utilization` | Commitments |
+| Realized savings (RI, SP, AHB) | `scan_savings_realized` | Commitments |
+| Budget consumption vs thresholds | `scan_budget_status` | Monitoring |
+| Cost anomaly alerts + detection rules | `scan_anomaly_alerts` | Monitoring |
+| Advisor cost recommendations | `scan_optimization_advice` | Advisor |
+| Billing account hierarchy (EA/MCA/CSP) | `scan_billing_structure` | Account |
+| Agreement, offer, currency, support plan | `scan_contract_info` | Account |
+| Full FinOps assessment across all modules | `run_full_scan` | Assessment |
+
+## Write / remediation tools
+
+Four tools can change Azure. They are **not** part of `run_full_scan` and never run implicitly. Each is **dry-run by default** and routes through the write-safety gate. Treat them as opt-in actions a user explicitly approves, not scans.
+
+| Action | Tool | Reversible? |
+| ------------------------------------------------------------- | ------------------------------------ | -------------------------------- |
+| Delete one orphaned resource (disk, NIC, public IP, snapshot) | `remediate_delete_orphaned_resource` | No - irreversible |
+| Deallocate one idle VM | `remediate_deallocate_vm` | Yes - start the VM to undo |
+| Enable Azure Hybrid Benefit on one VM | `remediate_enable_hybrid_benefit` | Yes - savings-only |
+| Create/update a native cost allocation rule (chargeback) | `set_cost_allocation_rule` | Yes - update/deactivate the rule |
+
+How to drive them safely:
+
+1. **Always preview first.** Call with `apply=false` (the default). The result shows the exact change and a `ConfirmationToken`.
+2. **Show the user the preview and get explicit approval.** Never set `apply=true` on your own initiative.
+3. **Then apply.** Call again with `apply=true`. In `Enforced` mode you must also pass the `confirmationToken` from the matching preview.
+4. **Writes are opt-in.** If `FINOPS_WRITE_MODE` is unset or `ReadOnly` (the default), every write is blocked - the tool returns a `Blocked` result explaining how to enable writes. Do not tell the user a change was applied unless the result has `Applied = true`.
+
+## FinOps Hub data paths (cost scans)
+
+The cost-family scans (`scan_cost_data`, `scan_resource_costs`, `scan_cost_by_tag`) read from a FinOps Hub when one is available, choosing a path automatically. Call `detect_cost_data_source` first to see which path covers the scope and how fresh it is. Two of the three paths push aggregation **into the Kusto engine** and return only summarized results, so they scale to large customer datasets (tens of GB / hundreds of millions of rows) — the raw rows are never loaded into PowerShell:
+
+| Path | When | Notes |
+| ------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | Cluster discovered via Resource Graph; aggregation runs in KQL against the `Costs` function. Scalable. |
+| **Kusto — offline (ftklocal)** | The exports loaded into a local ftklocal Kusto emulator | Set `FINOPS_HUB_KUSTO_URI` (anonymous local query). Scalable. |
+| **Storage export reader** | Small datasets, or no Kusto cluster present | Reads hub parquet/CSV and aggregates in PowerShell. Convenience fallback, not the scalable path. |
+
+When a result's `source` is `FinOpsHubKusto`, it came from the engine (summaries only). Forecast is not included on the hub fast paths — call with `dataSource=api` for a live forecast. The `dataSource` argument (`auto` default / `hub` / `api`) lets you force the hub path or the live Cost Management API.
+
+## Scope: subscriptionId
+
+Every tool takes an optional `subscriptionId`.
+
+- **Omit it** and the tool scans **every accessible subscription**. In large tenants this can mean hundreds of subscriptions — slow, and the result is written to a file you must read back.
+- **Pass it** to scope to one subscription. Prefer this when the user only has access to (or only cares about) a single subscription, or when iterating quickly.
+
+Always confirm scope before a broad scan if the tenant is large. If the user says they only have access to one subscription, always pass that `subscriptionId`.
+
+## Tool dependencies
+
+Three tools depend on inventory data being gathered first. When calling them individually, run the prerequisite first:
+
+| Tool | Run first |
+| ----------------------------- | ----------------------- |
+| `scan_cost_by_tag` | `scan_tag_inventory` |
+| `scan_tag_recommendations` | `scan_tag_inventory` |
+| `scan_policy_recommendations` | `scan_policy_inventory` |
+
+`run_full_scan` handles this chaining automatically — it gathers inventory before the dependent modules, so you don't need to sequence calls yourself when running the full assessment.
+
+## run_full_scan
+
+`run_full_scan` executes every module (Optimization, Governance, Cost Analysis, Commitments, Monitoring, Advisor) and returns one comprehensive object. Use the optional `modules` array to run a subset:
+
+- Full assessment: call with no arguments (or just `subscriptionId`).
+- Targeted multi-module: pass `modules` (e.g., `["scan_cost_by_tag"]`) to run only those, with dependencies resolved automatically.
+
+Prefer individual tools for a single question — `run_full_scan` is heavier and returns a large payload.
+
+## Reading results
+
+Tool output is JSON. Large results (tag inventory, full scans) are written to a file and the tool returns the file path — read that file to get the data. Each result includes a `permission` block (`role`, `scope`, `api`) confirming the read-only access path used.
+
+When summarizing for the user:
+
+- Lead with the headline number (count, total savings, coverage %).
+- Group findings by impact (High/Medium/Low) when the data provides it.
+- Translate raw findings into action (e.g., "3 deallocated VMs — the disks keep billing; delete if unused").
+- Surface the cost driver, not just the inventory.
+
+## Interpreting common results
+
+- **`scan_cost_by_tag` returns `NoTagsFound` but resources are tagged.** The tag exists on resources but isn't appearing in cost data. Two usual causes: (1) the tag isn't enabled as a **cost-allocation dimension** in Cost Management settings, or (2) **month-to-date lag** — tag-dimensioned cost data hasn't populated yet. Run `scan_tag_inventory` to confirm the tag is applied, then advise enabling tag-based cost allocation.
+- **Deallocated VMs in `scan_orphaned_resources`.** Compute isn't billing, but attached managed disks and static public IPs still are. Recommend deleting if truly unused.
+- **Low tag coverage in `scan_tag_inventory`.** Pair with `scan_tag_recommendations` to flag casing/duplicate issues (e.g., `managed_by` vs `managedBy`) and missing CAF-standard tags.
+- **Empty cost results early in the month.** Cost Management data lags; note this rather than reporting "$0".
+
+## FinOps skill ecosystem
+
+The multitool is the data engine. Once a scan surfaces a finding, hand off to the skill that turns it into a decision, a design, or an artifact. Treat this as the routing hub for the wider FinOps practice:
+
+| After this scan / question | Hand off to | For |
+| -------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| Any spend question (`scan_cost_data`, `scan_resource_costs`, `scan_cost_by_tag`) | `cost-data-source` | Choose the scalable hub (Kusto) or storage path vs the live API, warn before slow scans, chunk large tenants |
+| `scan_tag_inventory`, `scan_cost_by_tag` | `cost-allocation` | Showback/chargeback model, shared-cost splitting, tag strategy |
+| `scan_tag_recommendations`, `scan_policy_recommendations` | `azure-policy-governance` | Enforce tags/regions/SKUs via Azure Policy (Bicep/ARM) |
+| `scan_policy_inventory` results | `azure-workbooks-finops` | Live in-portal Governance/Optimization workbooks |
+| `scan_cost_trend`, `scan_resource_costs` | `power-bi-finops` | Dashboards and visuals on cost data |
+| `scan_savings_realized`, `scan_commitment_utilization` | `unit-economics` | ESR, cost-per-unit, coverage/utilization KPIs |
+| `scan_reservation_advice`, `scan_ahb_opportunities` | `rate-optimization-portfolio` | RI/SP/AHB portfolio mix and purchase planning |
+| `scan_budget_status`, `scan_cost_data` | `forecasting-budgeting` | Forecasts, budget design, variance analysis |
+| `scan_anomaly_alerts` | `anomaly-investigation` | Root-cause a spike down to the resource/change |
+| `scan_orphaned_resources`, `scan_idle_vms` | `sustainability-carbon` | Carbon co-benefit of removing waste |
+| Any deep KQL / FinOps hub query | `finops-toolkit` | Kusto analytics on the Hub database |
+| Single-instrument commitment mechanics | `azure-cost-management` | Reservations, savings plans, budgets, exports detail |
+| Cost data looks wrong/incomplete | `focus-data-quality` | FOCUS conformance, completeness, mapping |
+| Any finding the user wants written up | `finops-reporting` | Exec summaries, QBRs, variance narratives |
+
+Run the scan first to ground the numbers, then route to the matching skill — don't answer governance, allocation, or reporting questions abstractly when a scan can provide the real state.
+
+## Prerequisites
+
+- The `finops-multitool` MCP server must be running. It's defined in `.vscode/mcp.json` and started via the MCP server list in VS Code.
+- An authenticated Azure session is required (`Connect-AzAccount`). Tools fail or hang without it.
+- The scan tools are read-only (Reader) and advisory. Four write/remediation tools can modify Azure, but only when an operator opts in via `FINOPS_WRITE_MODE` (default `ReadOnly` blocks all writes) and only after an explicit `apply=true` on the specific previewed change.
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..8dabb8398
--- /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 MCP server, 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 `run_full_scan` 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? (`scan_cost_trend`, `monthly-cost-trend.kql`)
+3. **Top movers** — the 3 services/resource groups driving the change.
+4. **Savings captured** — ESR and realized savings (`scan_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` (`run_full_scan` 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..4e0175aa5
--- /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 MCP server.
+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..f8211d5cf
--- /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 MCP server.
+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 `scan_cost_trend` / `monthly-cost-trend.kql` and current budget state from `scan_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` (`scan_cost_trend`, `scan_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..daf9eafbe
--- /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 MCP server 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` MCP tools 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 a live read-only scan to seed a visual → `finops-multitool` MCP tools (`scan_cost_trend`, `scan_resource_costs`, `scan_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..eb3fe793c
--- /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 MCP server 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? (`scan_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 (`scan_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 (`scan_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..ab377cc9f
--- /dev/null
+++ b/src/templates/agent-skills/sustainability-carbon/SKILL.md
@@ -0,0 +1,57 @@
+---
+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 MCP server 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 |
+|------|----------|
+| **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 (`scan_orphaned_resources`, `scan_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` (`scan_orphaned_resources`, `scan_idle_vms`, `scan_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..ad916c536
--- /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 MCP server.
+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 | `scan_commitment_utilization`, `commitment-discount-utilization.kql` |
+| **Commitment utilization** | Used commitment ÷ purchased commitment | `scan_commitment_utilization` |
+| **Waste %** | Idle/orphaned cost ÷ total cost | `scan_orphaned_resources`, `scan_idle_vms` |
+| **Allocation coverage** | Allocated cost ÷ total cost | `scan_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 `scan_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`.