From 3c89c4358a875b89d1f87faa07edb3df6cb6cb61 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 19 May 2026 14:08:21 -0600 Subject: [PATCH 01/79] feat: add Start-FinOpsMultitool cmdlet for interactive FinOps scanner GUI Adds the Azure FinOps Multitool as a new PowerShell cmdlet in the FinOps toolkit. The Multitool is a WPF-based GUI that scans an Azure tenant for cost optimization, governance, and FinOps insights including cost trends, orphaned resources, idle VMs, tag hygiene, reservation/savings plan utilization, AHB opportunities, budgets, anomaly alerts, and policy compliance. - Public/Start-FinOpsMultitool.ps1: thin launcher cmdlet with comment-based help - Private/FinOpsMultitool/: full implementation (24 scanner modules, WPF GUI, Power BI template) - Tests/Unit/Start-FinOpsMultitool.Tests.ps1: Pester unit tests Windows-only (requires WPF support). --- .../Private/FinOpsMultitool/LICENSE | 21 + .../FinOpsMultitool/Start-FinOpsMultitool.ps1 | 5322 +++++++++++++++++ .../FinOpsMultitool/gui/MainWindow.xaml | 765 +++ .../Private/FinOpsMultitool/gui/app.ico | Bin 0 -> 901 bytes .../Private/FinOpsMultitool/gui/skeleton.pbit | Bin 0 -> 11036 bytes .../modules/Deploy-PolicyAssignment.ps1 | 212 + .../modules/Deploy-ResourceTag.ps1 | 217 + .../modules/Get-AHBOpportunities.ps1 | 97 + .../modules/Get-AnomalyAlerts.ps1 | 138 + .../modules/Get-BillingStructure.ps1 | 201 + .../modules/Get-BudgetStatus.ps1 | 186 + .../modules/Get-CommitmentUtilization.ps1 | 274 + .../modules/Get-ContractInfo.ps1 | 126 + .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 283 + .../FinOpsMultitool/modules/Get-CostData.ps1 | 294 + .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 174 + .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 143 + .../modules/Get-OptimizationAdvice.ps1 | 173 + .../modules/Get-OrphanedResources.ps1 | 216 + .../modules/Get-PolicyInventory.ps1 | 283 + .../modules/Get-PolicyRecommendations.ps1 | 251 + .../modules/Get-ReservationAdvice.ps1 | 161 + .../modules/Get-ResourceCosts.ps1 | 346 ++ .../modules/Get-SavingsRealized.ps1 | 269 + .../modules/Get-StorageTierAdvice.ps1 | 123 + .../modules/Get-TagInventory.ps1 | 200 + .../modules/Get-TagRecommendations.ps1 | 170 + .../modules/Get-TenantHierarchy.ps1 | 124 + .../modules/Initialize-Scanner.ps1 | 256 + .../Public/Start-FinOpsMultitool.ps1 | 55 + .../Unit/Start-FinOpsMultitool.Tests.ps1 | 53 + 31 files changed, 11133 insertions(+) create mode 100644 src/powershell/Private/FinOpsMultitool/LICENSE create mode 100644 src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/gui/MainWindow.xaml create mode 100644 src/powershell/Private/FinOpsMultitool/gui/app.ico create mode 100644 src/powershell/Private/FinOpsMultitool/gui/skeleton.pbit create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 create mode 100644 src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 create mode 100644 src/powershell/Public/Start-FinOpsMultitool.ps1 create mode 100644 src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 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/Start-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1 new file mode 100644 index 000000000..4111b22ae --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1 @@ -0,0 +1,5322 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# FINOPSMULTITOOL +########################################################################### +# Purpose: Launch the AZURE FINOPS MULTITOOL WPF application. Authenticates +# to Azure, scans the tenant for cost/tag/optimization data, and +# displays results in an interactive GUI. +# +# Usage: .\Start-FinOpsMultitool.ps1 +# +# Requirements: +# - PowerShell 5.1+ (Windows) or 7+ with WindowsCompatibility +# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph, +# Az.CostManagement, Az.Advisor, Az.Billing +# - Azure RBAC: Reader + Cost Management Reader on target scope +########################################################################### + +#Requires -Version 5.1 + +# -- Load WPF Assemblies ------------------------------------------------ +Add-Type -AssemblyName PresentationFramework +Add-Type -AssemblyName PresentationCore +Add-Type -AssemblyName WindowsBase + +# -- Shared Helper: Get-PlainAccessToken ------------------------------------ +# Get-AzAccessToken returns SecureString in Az.Accounts >= 3.0. +# This helper always returns a plain-text bearer token string. +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 } +} + +# -- Shared Helper: Invoke-AzRestMethodWithRetry ---------------------------- +# Wraps Invoke-AzRestMethod with: +# - Background runspace with 60s timeout (prevents indefinite hangs) +# - Automatic retry on HTTP 429 (throttling) with DispatcherFrame UI wait +# Cost Management API rate-limits aggressively; per-sub queries across +# multiple scan stages can exhaust the quota quickly. +function Invoke-AzRestMethodWithRetry { + param( + [string]$Path, + [string]$Method = 'POST', + [string]$Payload, + [int]$MaxRetries = 3, + [int]$TimeoutSeconds = 60 + ) + for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) { + # Run Invoke-AzRestMethod in a background runspace so it can be + # killed on timeout (the cmdlet has no TimeoutSec parameter). + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript({ + param($p, $m, $pl) + $params = @{ Path = $p; Method = $m; ErrorAction = 'Stop' } + if ($pl) { $params['Payload'] = $pl } + $r = Invoke-AzRestMethod @params + # Return a simple hashtable that survives runspace serialization + $hdrs = @{} + if ($r.Headers) { + foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } + } + [PSCustomObject]@{ + StatusCode = $r.StatusCode + Content = $r.Content + Headers = $hdrs + } + }).AddArgument($Path).AddArgument($Method).AddArgument($Payload) + + $asyncResult = $ps.BeginInvoke() + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + + # DispatcherFrame loop keeps WPF UI responsive while waiting + 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 + } + + $resp = $null + if ($asyncResult.IsCompleted) { + try { + $raw = $ps.EndInvoke($asyncResult) + $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + } catch { + $ps.Dispose(); $rs.Close() + throw + } + } else { + $ps.Stop() + Write-Warning " REST call timed out after $($TimeoutSeconds)s: $Method $Path" + $ps.Dispose(); $rs.Close() + # Return a synthetic timeout response + return [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Request timed out"}}'; Headers = @{} } + } + + $ps.Dispose() + $rs.Close() + + # Ensure we never return null or a response with null Content + 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) + } + Write-Host " [429 Throttled] Waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..." -ForegroundColor Yellow + + # Update status bar if available + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Rate limited - waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..." + } + + # Dispatcher-friendly wait: DispatcherFrame nested message loop + $waitEnd = (Get-Date).AddSeconds($retryAfter) + 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 + } + } + return $resp # Return last 429 response if all retries exhausted +} + +# -- Shared 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 +} + +# -- Shared Helper: Search-AzGraphSafe ------------------------------------ +# Wraps Search-AzGraph with: +# - 60-second timeout via background runspace (prevents indefinite hangs) +# - Automatic retry on 429 throttling with DispatcherFrame UI-responsive wait +# - Returns $null on timeout so callers can handle gracefully +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++) { + # Build Search-AzGraph in a background runspace so it can be killed on timeout + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + [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 + # Serialize data to JSON inside the runspace to preserve nested + # property hierarchy. Deserialized PSObjects lose navigability + # for deep properties like $row.properties.displayName. + $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() + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + + # DispatcherFrame loop keeps WPF UI responsive while waiting + 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 + } + + $result = $null + $is429 = $false + if ($asyncResult.IsCompleted) { + try { + $raw = $ps.EndInvoke($asyncResult) + # EndInvoke returns PSDataCollection; unwrap to get our PSCustomObject + $wrapper = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + if ($wrapper) { + # Re-hydrate data from JSON to restore nested property hierarchy + $data = if ($wrapper.JsonData -and $wrapper.JsonData -ne '[]') { + $parsed = $wrapper.JsonData | ConvertFrom-Json + # ConvertFrom-Json returns single object if 1 row, wrap in array + if ($parsed -is [array]) { $parsed } else { @($parsed) } + } else { @() } + $result = [PSCustomObject]@{ + Data = $data + SkipToken = $wrapper.SkipToken + Count = $wrapper.Count + } + } + # Check for 429 errors in the error stream + 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(); $rs.Close(); throw } + } + } else { + $ps.Stop() + Write-Warning " Resource Graph query timed out after $($TimeoutSeconds)s" + } + + $ps.Dispose() + $rs.Close() + + # If not 429, return whatever we got + if (-not $is429) { return $result } + + # 429 retry with DispatcherFrame wait + $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 30) + Write-Host " [429 Throttled - Resource Graph] Waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..." -ForegroundColor Yellow + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Resource Graph rate limited - waiting $($retryAfter)s..." + } + $waitEnd = (Get-Date).AddSeconds($retryAfter) + 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 + } + } + return $null # All retries exhausted +} + +# -- Dot-Source Modules ------------------------------------------------- +$script:ScriptRootDir = $PSScriptRoot +$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-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 'Get-BudgetStatus.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') + +# -- Load XAML ---------------------------------------------------------- +$xamlPath = Join-Path $PSScriptRoot 'gui\MainWindow.xaml' +$xamlContent = Get-Content $xamlPath -Raw + +# Remove x:Name -> Name for FindName compatibility +$xamlContent = $xamlContent -replace 'x:Name=', 'Name=' +# Remove x:Key and x:Class attributes that cause parse issues +$xamlContent = $xamlContent -replace 'x:Class="[^"]*"', '' + +$reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xamlContent)) +$window = [System.Windows.Markup.XamlReader]::Load($reader) +$script:window = $window + +# Set custom window icon +$icoPath = Join-Path $PSScriptRoot 'gui\app.ico' +if (Test-Path $icoPath) { + $iconUri = [System.Uri]::new($icoPath) + $window.Icon = [System.Windows.Media.Imaging.BitmapFrame]::Create($iconUri) +} + +# -- Find Named Controls ----------------------------------------------- +$controls = @( + 'TenantLabel', 'VersionLabel', 'TenantButton', 'GovTenantButton', 'ScanButton', 'ExportButton', + 'ProgressBar', 'StatusText', 'HierarchyTree', 'DetailTabs', + # Overview + 'ContractTypeText', 'ContractDetailText', 'TotalCostText', + 'ForecastText', 'SubCountText', 'TotalSavingsText', 'SubCostGrid', + 'ResourceCostGrid', + 'ResourceCountNote', + # Cost Analysis + 'TrendChart', 'TrendNote', 'TrendSubSelector', + 'TagSelector', 'CostByTagGrid', 'NoTagsLabel', + # Tags + 'TagCountText', 'TagCoverageText', 'UntaggedCountText', + 'TagInventoryGrid', 'TagComplianceText', 'TagRecsGrid', + 'UntaggedNote', 'UntaggedResourcesGrid', + 'CustomTagButton', 'TagDeployPanel', 'TagDeployTitle', + 'TagNameLabel', 'TagNameInput', + 'TagScopeSelector', 'TagValueInput', 'TagDeployButton', + 'TagDeployCancelButton', 'TagDeployStatus', + # Overview - Budget & Scorecard + 'SavingsRealizedText', 'SavingsRealizedDetail', + 'BudgetSummaryText', 'BudgetGrid', 'ScorecardGrid', + # Cost Analysis - Anomalies + 'AnomalyNote', 'AnomalyGrid', + # Cost Analysis - API Alerts + 'AlertsSummaryNote', 'TriggeredAlertsGrid', 'ConfiguredRulesGrid', + # Optimization + 'AHBCountText', 'AHBDetailText', 'OrphanCountText', 'OrphanDetailText', + 'RIUtilText', 'RIUtilDetail', 'RIContractNote', 'SPContractNote', + 'AdvisorCountText', 'AdvisorSavingsText', 'AHBSummaryText', + 'AHBGrid', 'RIGrid', 'SPGrid', 'AdvisorGrid', + 'CommitmentGrid', 'OrphanGrid', 'OrphanSummaryText', + 'IdleVMGrid', 'IdleVMSummaryText', + 'StorageTierGrid', 'StorageTierSummaryText', + # Resources Tab + 'ResourcesPanel', 'ResourcesFinOpsPanel', 'ResourcesCostPanel', + 'ResourcesRatePanel', 'ResourcesGovernancePanel', 'ResourcesToolsPanel', + # Billing + 'BillingAccessNote', 'BillingAccountsGrid', 'BillingProfilesGrid', + 'InvoiceSectionsGrid', 'EADeptHeader', 'EADeptGrid', 'CostAllocationGrid', + # Budgets Tab + 'BudgetSubSelector', 'BudgetSubSummary', 'BudgetDetailGrid', + 'BudgetDeployPanel', 'BudgetDeployScopeSelector', + 'BudgetDeployNameInput', 'BudgetDeployAmountInput', 'BudgetDeployGrainSelector', + 'BudgetDeployEmailInput', 'BudgetActionGroupSelector', + 'BudgetThreshold1', 'BudgetThreshold1Type', + 'BudgetThreshold2', 'BudgetThreshold2Type', + 'BudgetThreshold3', 'BudgetThreshold3Type', + 'BudgetThreshold4', 'BudgetThreshold4Type', + 'BudgetDeployTagNameSelector', 'BudgetDeployTagValueInput', + 'BudgetDeployButton', 'BudgetDeployCancelButton', 'BudgetDeployStatus', + 'BudgetPolicyPanel', 'BudgetPolicyEffectSelector', 'BudgetPolicyScopeSelector', + 'BudgetPolicyDeployButton', 'BudgetPolicyCancelButton', 'BudgetPolicyStatus', + # Guidance + 'GuidanceScorePanel', 'ActionPlanSubtitle', 'ActionPlanPanel', + 'UnderstandPanel', 'QuantifyPanel', 'OptimizePanel', + 'PersonasPanel', + # Policy + 'PolicyCountText', 'PolicyComplianceText', 'PolicyNonCompliantText', + 'PolicyRecsCountText', 'PolicyInventoryGrid', 'PolicyComplianceGrid', + 'PolicyRecsComplianceText', 'PolicyRecsGrid', + 'PolicyDeployPanel', 'PolicyDeployTitle', 'PolicyScopeSelector', + 'PolicyEffectSelector', 'PolicyParamsPanel', 'PolicyDeployButton', + 'PolicyRemediateButton', 'PolicyDeployCancelButton', 'PolicyDeployStatus' +) + +foreach ($name in $controls) { + $ctrl = $window.FindName($name) + if ($ctrl) { Set-Variable -Name $name -Value $ctrl -Scope Script } +} + +# -- Global Scan Data -------------------------------------------------- +$script:scanData = @{ + Auth = $null + Hierarchy = $null + Contract = $null + Costs = $null + ResourceCosts = $null + Tags = $null + CostByTag = $null + CostTrend = $null + AHB = $null + Reservations = $null + Optimization = $null + TagRecs = $null + Billing = $null + Commitments = $null + Orphans = $null + Budgets = $null + Savings = $null + PolicyInv = $null + PolicyRecs = $null + StorageTier = $null + IdleVMs = $null +} + +# -- Session Action Log (tags deployed/removed, policies assigned/unassigned) -- +$script:actionLog = [System.Collections.Generic.List[PSCustomObject]]::new() + +########################################################################### +# HELPER FUNCTIONS +########################################################################### + +function Update-UIStatus { + param([string]$Message, [int]$Percent) + $script:StatusText.Text = $Message + $script:ProgressBar.Value = $Percent + # Force UI refresh + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [action]{}, [System.Windows.Threading.DispatcherPriority]::Background + ) +} + +# Lightweight status update for modules to call mid-loop (no progress bar change). +# Keeps the UI responsive during long per-subscription iterations. +function Update-ScanStatus { + param([string]$Message) + if ($script:StatusText) { + $script:StatusText.Text = $Message + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [action]{}, [System.Windows.Threading.DispatcherPriority]::Background + ) + } +} + +function Get-CurrencySymbol { + param([string]$Code) + switch ($Code) { + 'USD' { '$' } + 'EUR' { [char]0x20AC } + 'GBP' { [char]0x00A3 } + 'JPY' { [char]0x00A5 } + 'CAD' { 'C$' } + 'AUD' { 'A$' } + 'CHF' { 'CHF ' } + 'INR' { [char]0x20B9 } + 'BRL' { 'R$' } + 'KRW' { [char]0x20A9 } + 'MXN' { 'MX$' } + 'SEK' { 'kr ' } + 'NOK' { 'kr ' } + 'DKK' { 'kr ' } + 'ZAR' { 'R ' } + default { "$Code " } + } +} + +# -- Tree View Population ---------------------------------------------- +function Add-HierarchyNode { + param( + [object]$Group, + [System.Windows.Controls.ItemsControl]$Parent, + [hashtable]$CostMap, + [object[]]$Subscriptions + ) + + $groupItem = [System.Windows.Controls.TreeViewItem]::new() + $groupItem.Header = "[MG] $($Group.DisplayName)" + $groupItem.IsExpanded = $true + $groupItem.Tag = @{ Type = 'MG'; Id = $Group.Name; Name = $Group.DisplayName } + $groupItem.FontWeight = 'SemiBold' + $Parent.Items.Add($groupItem) | Out-Null + + if ($Group.Children) { + foreach ($child in $Group.Children) { + if ($child.Type -eq '/subscriptions') { + $subItem = [System.Windows.Controls.TreeViewItem]::new() + $cost = '' + if ($CostMap -and $CostMap.ContainsKey($child.Name)) { + $c = $CostMap[$child.Name] + $cost = " [$($c.Currency) $($c.Actual.ToString('N2'))]" + } + $subItem.Header = "[$] $($child.DisplayName)$cost" + $subItem.Tag = @{ Type = 'Sub'; Id = $child.Name; Name = $child.DisplayName } + $subItem.FontWeight = 'Normal' + $groupItem.Items.Add($subItem) | Out-Null + } + elseif ($child.Children -or $child.Type -match 'managementGroups') { + Add-HierarchyNode -Group $child -Parent $groupItem -CostMap $CostMap -Subscriptions $Subscriptions + } + } + } +} + +# -- Tab Population Functions ------------------------------------------ +function Populate-OverviewTab { + $d = $script:scanData + + # Contract + if ($d.Contract -and $d.Contract.Count -gt 0) { + $primary = $d.Contract[0] + $script:ContractTypeText.Text = $primary.FriendlyType + $script:ContractDetailText.Text = $primary.AccountName + } + + # Subscription count + $subCount = $d.Auth.Subscriptions.Count + $skippedCount = if ($d.Auth.SkippedSubs) { $d.Auth.SkippedSubs.Count } else { 0 } + if ($skippedCount -gt 0) { + $script:SubCountText.Text = "$subCount (+$skippedCount skipped)" + } else { + $script:SubCountText.Text = $subCount.ToString() + } + + # Total costs + $totalActual = 0; $totalForecast = 0; $currency = 'USD' + if ($d.Costs) { + foreach ($entry in $d.Costs.GetEnumerator()) { + $totalActual += $entry.Value.Actual + $totalForecast += $entry.Value.Forecast + $currency = $entry.Value.Currency + } + } + $script:TotalCostText.Text = "$(Get-CurrencySymbol $currency)$($totalActual.ToString('N2'))" + $script:ForecastText.Text = "$(Get-CurrencySymbol $currency)$($totalForecast.ToString('N2'))" + + # Total savings + $totalSavings = 0 + if ($d.Optimization) { $totalSavings += $d.Optimization.EstimatedAnnualSavings } + if ($d.Reservations) { $totalSavings += $d.Reservations.EstimatedAnnualSavings } + $script:TotalSavingsText.Text = "`$$($totalSavings.ToString('N2'))/yr" + + # Savings Realized card + if ($d.Savings) { + $sym = Get-CurrencySymbol $currency + $script:SavingsRealizedText.Text = "$sym$($d.Savings.TotalMonthly.ToString('N2'))/mo" + $parts = @() + if ($d.Savings.RISavingsMonthly -gt 0) { $parts += "RI: $sym$($d.Savings.RISavingsMonthly.ToString('N0'))" } + if ($d.Savings.SPSavingsMonthly -gt 0) { $parts += "SP: $sym$($d.Savings.SPSavingsMonthly.ToString('N0'))" } + if ($d.Savings.AHBSavingsMonthly -gt 0) { $parts += "AHB: $sym$($d.Savings.AHBSavingsMonthly.ToString('N0'))" } + $script:SavingsRealizedDetail.Text = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'No existing commitment savings detected' } + } + + # Subscription cost grid + $subRows = [System.Collections.Generic.List[PSCustomObject]]::new() + $totalSubActual = 0 + if ($d.Costs) { + foreach ($entry in $d.Costs.GetEnumerator()) { $totalSubActual += $entry.Value.Actual } + } + 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' } } + $pct = if ($totalSubActual -gt 0) { [math]::Round(($c.Actual / $totalSubActual) * 100, 2) } else { 0 } + + # Estimate orphan savings for this sub + $orphanSave = 0.0 + if ($d.Orphans -and $d.Orphans.Orphans) { + $subOrphans = @($d.Orphans.Orphans | Where-Object { $_.SubscriptionId -eq $sub.Id }) + foreach ($o in $subOrphans) { + $orphanSave += switch ($o.Category) { + 'Orphaned Disk' { + $diskGb = 0 + if ($o.Detail -match '(\d+)\s*GB') { $diskGb = [int]$Matches[1] } + if ($o.Detail -match 'Premium') { $diskGb * 0.12 } + elseif ($o.Detail -match 'Standard_SSD') { $diskGb * 0.075 } + else { $diskGb * 0.04 } + } + 'Unattached Public IP' { 3.65 } + 'Unattached NIC' { 0 } + 'Deallocated VM' { 15 } + 'Empty App Service Plan' { 55 } + 'Old Snapshot' { 5 } + default { 5 } + } + } + } + $sym = Get-CurrencySymbol $c.Currency + + [void]$subRows.Add([PSCustomObject]@{ + Subscription = $sub.Name + 'Actual (MTD)' = $c.Actual.ToString('N2') + 'Forecast' = $c.Forecast.ToString('N2') + '% of Total' = "$pct%" + Currency = $c.Currency + }) + } + $script:SubCostGrid.ItemsSource = @($subRows | Sort-Object { [double]($_.'Actual (MTD)') } -Descending) + + # Resource cost grid — dynamic threshold: include resources >= 0.1% of total forecast + if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { + $totalActualAll = ($d.ResourceCosts | Measure-Object -Property Actual -Sum).Sum + $sorted = @($d.ResourceCosts | Sort-Object { $_.Actual } -Descending) + $totalResources = $sorted.Count + + # Dynamic spend threshold: 0.1% of total actual spend (minimum $1 to filter noise) + $threshold = [math]::Max(1.0, $totalActualAll * 0.001) + $display = @($sorted | Where-Object { $_.Actual -ge $threshold }) + # Safety: if threshold filters everything, show top 50 + if ($display.Count -eq 0) { $display = @($sorted | Select-Object -First 50) } + + $resRows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($r in $display) { + $pct = if ($totalActualAll -gt 0) { [math]::Round(($r.Actual / $totalActualAll) * 100, 2) } else { 0 } + [void]$resRows.Add([PSCustomObject]@{ + 'Resource Group' = $r.ResourceGroup + 'Resource Type' = $r.ResourceType + 'Actual (MTD)' = $r.Actual.ToString('N2') + 'Forecast' = $r.Forecast.ToString('N2') + '% of Total' = "$pct%" + 'Currency' = $r.Currency + 'Resource Path' = $r.ResourcePath + }) + } + $script:ResourceCostGrid.ItemsSource = @($resRows) + + $excluded = $totalResources - $display.Count + if ($excluded -gt 0) { + $script:ResourceCountNote.Text = "$($display.Count) of $totalResources resources shown (threshold: $(Get-CurrencySymbol $currency)$($threshold.ToString('N2'))/mo MTD, $excluded below threshold)" + } else { + $script:ResourceCountNote.Text = "$totalResources resources" + } + } + + # Populate tree + $script:HierarchyTree.Items.Clear() + if ($d.Hierarchy -and $d.Hierarchy.RootGroup) { + Add-HierarchyNode -Group $d.Hierarchy.RootGroup -Parent $script:HierarchyTree ` + -CostMap $d.Costs -Subscriptions $d.Auth.Subscriptions + } + elseif ($d.Hierarchy -and $d.Hierarchy.FlatSubs) { + foreach ($sub in $d.Hierarchy.FlatSubs) { + $item = [System.Windows.Controls.TreeViewItem]::new() + $cost = '' + if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { + $c = $d.Costs[$sub.Id] + $cost = " [$($c.Currency) $($c.Actual.ToString('N2'))]" + } + $item.Header = "[$] $($sub.Name)$cost" + $item.Tag = @{ Type = 'Sub'; Id = $sub.Id; Name = $sub.Name } + $script:HierarchyTree.Items.Add($item) | Out-Null + } + } +} + +function Populate-CostTab { + $d = $script:scanData.CostByTag + + if (-not $d -or $d.NoTagsFound) { + $script:NoTagsLabel.Text = "[!] No cost-allocation tags found (CostCenter, Environment, Application, etc.). Without these tags, costs cannot be broken down by business dimension. See the Tags tab for recommended tags to implement." + return + } + + if ($script:TagSelector) { + $script:TagSelector.Items.Clear() + foreach ($tagName in $d.TagsQueried) { + $script:TagSelector.Items.Add($tagName) | Out-Null + } + if ($d.TagsQueried.Count -gt 0) { + $script:TagSelector.SelectedIndex = 0 + } + } +} + +function Populate-TagsTab { + $d = $script:scanData + + # Tag summary + if ($d.Tags) { + $script:TagCountText.Text = if ($null -ne $d.Tags.TagCount) { $d.Tags.TagCount.ToString() } else { '0' } + $script:TagCoverageText.Text = if ($null -ne $d.Tags.TagCoverage) { "$($d.Tags.TagCoverage)%" } else { '0%' } + $script:UntaggedCountText.Text = if ($null -ne $d.Tags.UntaggedCount) { $d.Tags.UntaggedCount.ToString('N0') } else { '0' } + + # Inventory grid - preserve all tag value casing variants for discovery + $script:TagInventoryGrid.AutoGenerateColumns = $false + $script:TagInventoryGrid.Columns.Clear() + + # Data columns + foreach ($col in @('Tag Name','Resources','Unique Values','Values')) { + $dgCol = [System.Windows.Controls.DataGridTextColumn]::new() + $dgCol.Header = $col + $dgCol.Binding = [System.Windows.Data.Binding]::new($col) + if ($col -eq 'Values') { + $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star) + $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock]) + $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap)) + } + $script:TagInventoryGrid.Columns.Add($dgCol) + } + + # Action button template column (Remove) + $invActionCol = [System.Windows.Controls.DataGridTemplateColumn]::new() + $invActionCol.Header = 'Action' + $invActionCol.Width = 75 + + $invCellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button]) + $invCellFactory.SetValue([System.Windows.Controls.Button]::ContentProperty, 'Remove') + $invCellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('Tag Name')) + $invCellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10) + $invCellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6,1,6,1)) + $invCellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2,1,2,1)) + $invCellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand) + $invCellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1)) + $invCellFactory.SetValue([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#FDE7E9')) + $invCellFactory.SetValue([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438')) + $invCellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ + param($sender, $e) + Show-TagRemovePanel -TagName $sender.Tag + }) + + $invCellTemplate = [System.Windows.DataTemplate]::new() + $invCellTemplate.VisualTree = $invCellFactory + $invActionCol.CellTemplate = $invCellTemplate + $script:TagInventoryGrid.Columns.Add($invActionCol) + + $tagRows = @() + foreach ($entry in $(if ($d.Tags.TagNames) { $d.Tags.TagNames.GetEnumerator() } else { @() })) { + $allValues = @($entry.Value.Values | ForEach-Object { $_.Value }) + $values = $allValues -join ', ' + $tagRows += [PSCustomObject]@{ + 'Tag Name' = $entry.Key + 'Resources' = $entry.Value.TotalResources + 'Unique Values' = $allValues.Count + 'Values' = $values + } + } + $script:TagInventoryGrid.ItemsSource = @($tagRows | Sort-Object 'Resources' -Descending) + + # Untagged resources detail grid + if ($d.Tags.UntaggedResources -and $d.Tags.UntaggedResources.Count -gt 0) { + $total = $d.Tags.UntaggedCount + $shown = $d.Tags.UntaggedResources.Count + if ($shown -lt $total) { + $script:UntaggedNote.Text = "Showing $shown of $total untagged resources" + } else { + $script:UntaggedNote.Text = "$shown untagged resource$(if($shown -ne 1){'s'})" + } + $script:UntaggedResourcesGrid.ItemsSource = @($d.Tags.UntaggedResources) + } else { + $script:UntaggedNote.Text = "No untagged resources found" + $script:UntaggedResourcesGrid.ItemsSource = @() + } + } + + # Tag recommendations with inline action buttons + if ($d.TagRecs) { + $presentCount = $d.TagRecs.Present.Count + $analysisCount = $d.TagRecs.Analysis.Count + $script:TagComplianceText.Text = "Tag compliance: $($d.TagRecs.CompliancePercent)% ($presentCount of $analysisCount recommended tags found)" + + # Build the tag recs grid with programmatic columns including an Action button + $script:TagRecsGrid.AutoGenerateColumns = $false + $script:TagRecsGrid.Columns.Clear() + + # Data columns + foreach ($col in @('Tag','Status','Location','Priority','Pillar','Purpose')) { + $dgCol = [System.Windows.Controls.DataGridTextColumn]::new() + $dgCol.Header = $col + $dgCol.Binding = [System.Windows.Data.Binding]::new($col) + if ($col -in @('Location','Purpose')) { + $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star) + $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock]) + $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap)) + } + $script:TagRecsGrid.Columns.Add($dgCol) + } + + # Action button template column + $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new() + $actionCol.Header = 'Action' + $actionCol.Width = 75 + + $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button]) + $cellFactory.SetBinding([System.Windows.Controls.Button]::ContentProperty, [System.Windows.Data.Binding]::new('ActionLabel')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Data.Binding]::new('ActionBg')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Data.Binding]::new('ActionFg')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('ActionTagName')) + $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10) + $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6,1,6,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2,1,2,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand) + $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1)) + $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ + param($sender, $e) + $tagName = $sender.Tag + $status = $sender.Content + if ($status -eq 'Add') { + Show-TagDeployPanel -TagName $tagName + } elseif ($status -eq 'Remove') { + Show-TagRemovePanel -TagName $tagName + } + }) + + $cellTemplate = [System.Windows.DataTemplate]::new() + $cellTemplate.VisualTree = $cellFactory + $actionCol.CellTemplate = $cellTemplate + $script:TagRecsGrid.Columns.Add($actionCol) + + # Populate rows with action metadata + $brushConv = [System.Windows.Media.BrushConverter]::new() + $recRows = $d.TagRecs.Analysis | ForEach-Object { + $isMissing = $_.Status -eq 'Missing' + # For Remove: use the actual tag name found in Azure (handles variations + correct case) + # For Add: use the recommended tag name + $actionTag = if ($isMissing) { $_.TagName } elseif ($_.ActualTagName) { $_.ActualTagName } else { $_.TagName } + [PSCustomObject]@{ + 'Tag' = $_.TagName + 'TagName' = $_.TagName + 'ActionTagName' = $actionTag + 'Status' = $_.Status + 'Location' = $_.Location + 'Priority' = $_.Priority + 'Pillar' = $_.Pillar + 'Purpose' = $_.Purpose + 'ActionLabel' = if ($isMissing) { 'Add' } else { 'Remove' } + 'ActionBg' = if ($isMissing) { $brushConv.ConvertFromString('#DFF6DD') } else { $brushConv.ConvertFromString('#FDE7E9') } + 'ActionFg' = if ($isMissing) { $brushConv.ConvertFromString('#107C10') } else { $brushConv.ConvertFromString('#D13438') } + } + } + $script:TagRecsGrid.ItemsSource = @($recRows) + } +} + +#----------------------------------------------------------------------- +# SHARED RESOURCE COST LOOKUP (used by Optimization + Orphan sections) +#----------------------------------------------------------------------- +$script:resCostMap = @{} +$script:resCostMapBuilt = $false + +function Build-ResourceCostMap { + $d = $script:scanData + $script:resCostMap = @{} + if ($d.ResourceCosts) { + foreach ($rc in $d.ResourceCosts) { + if ($rc.ResourcePath) { + $script:resCostMap[$rc.ResourcePath.ToLower()] = $rc + } + if ($rc.ResourcePath -match '/([^/]+)$') { + $nameKey = $Matches[1].ToLower() + if (-not $script:resCostMap.ContainsKey($nameKey)) { $script:resCostMap[$nameKey] = $rc } + } + } + } + $script:resCostMapBuilt = $true +} + +function Find-ResourceCost { + param($Name, $SubscriptionId, $ResourceGroup, $ResourceType) + if (-not $script:resCostMapBuilt) { Build-ResourceCostMap } + $rc = $null + if ($SubscriptionId -and $ResourceGroup -and $ResourceType -and $Name) { + $armId = "/subscriptions/$SubscriptionId/resourcegroups/$ResourceGroup/providers/$ResourceType/$Name".ToLower() + $rc = $script:resCostMap[$armId] + } + if (-not $rc -and $Name) { + $rc = $script:resCostMap[$Name.ToLower()] + } + return $rc +} + +function Populate-OptimizationTab { + $d = $script:scanData + + # Ensure shared resource cost map is built + if (-not $script:resCostMapBuilt) { Build-ResourceCostMap } + + # Currency helper + $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { + Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency + } else { '$' } + + # AHB + if ($d.AHB) { + $script:AHBCountText.Text = "$($d.AHB.TotalOpportunities) resources" + $script:AHBDetailText.Text = "$($d.AHB.WindowsVMs.Count) VMs, $($d.AHB.SQLVMs.Count) SQL VMs, $($d.AHB.SQLDatabases.Count) SQL DBs" + $script:AHBSummaryText.Text = $d.AHB.Summary + + $ahbRows = @() + foreach ($vm in $d.AHB.WindowsVMs) { + $rc = Find-ResourceCost -Name $vm.name -SubscriptionId $vm.subscriptionId -ResourceGroup $vm.resourceGroup -ResourceType 'microsoft.compute/virtualmachines' + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + # AHB saves ~40% on Windows VM licensing component + $ahbActual = if ($actual) { [math]::Round($actual * 0.6, 2) } else { $null } + $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.6, 2) } else { $null } + $ahbRows += [PSCustomObject]@{ + Type = 'Windows VM' + Name = $vm.name + ResourceGroup = $vm.resourceGroup + Size = $vm.vmSize + CurrentLicense = $vm.currentLicense + Location = $vm.location + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' } + 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' } + } + } + foreach ($sql in $d.AHB.SQLVMs) { + $rc = Find-ResourceCost -Name $sql.name -SubscriptionId $sql.subscriptionId -ResourceGroup $sql.resourceGroup -ResourceType 'microsoft.sqlvirtualmachine/sqlvirtualmachines' + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + $ahbActual = if ($actual) { [math]::Round($actual * 0.45, 2) } else { $null } + $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.45, 2) } else { $null } + $ahbRows += [PSCustomObject]@{ + Type = 'SQL VM' + Name = $sql.name + ResourceGroup = $sql.resourceGroup + Size = $sql.sqlEdition + CurrentLicense = $sql.currentLicense + Location = $sql.location + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' } + 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' } + } + } + foreach ($db in $d.AHB.SQLDatabases) { + $rc = Find-ResourceCost -Name $db.name -SubscriptionId $db.subscriptionId -ResourceGroup $db.resourceGroup -ResourceType 'microsoft.sql/servers/databases' + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + # AHB saves ~55% on SQL DB licensing component + $ahbActual = if ($actual) { [math]::Round($actual * 0.45, 2) } else { $null } + $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.45, 2) } else { $null } + $ahbRows += [PSCustomObject]@{ + Type = 'SQL Database' + Name = $db.name + ResourceGroup = $db.resourceGroup + Size = $db.sku + CurrentLicense = $db.currentLicense + Location = $db.location + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' } + 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' } + } + } + if ($ahbRows.Count -eq 0) { + $script:AHBGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No AHB-eligible resources found. All resources are using Azure Hybrid Benefit or are not eligible.' }) + } else { + $script:AHBGrid.ItemsSource = @($ahbRows) + } + } else { + $script:AHBGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No AHB-eligible resources found.' }) + } + + # Reservations - split RI vs SP + if ($d.Reservations) { + # Classify advisor recs as RI or SP + $riRecs = @() + $spRecs = @() + foreach ($rec in $d.Reservations.AdvisorRecommendations) { + if ($rec.Problem -match 'savings plan' -or $rec.Solution -match 'savings plan') { + $spRecs += $rec + } else { + $riRecs += $rec + } + } + + # Contract-aware note + $contractType = '' + if ($d.Contract -and $d.Contract.Count -gt 0) { + $contractType = $d.Contract[0].AgreementType + } + $contractNote = switch -Regex ($contractType) { + 'EnterpriseAgreement' { 'EA customers: RI/SP pricing reflects your negotiated EA rates. Savings shown are vs. your EA pay-as-you-go rate.' } + 'MicrosoftCustomerAgreement' { 'MCA customers: RI/SP savings are calculated against your MCA list prices. Actual savings may vary based on negotiated discounts.' } + 'MicrosoftOnlineServicesProgram' { 'PAYGO customers: Savings shown are vs. retail pay-as-you-go rates. Consider an EA or MCA for even deeper discounts on top of RI/SP.' } + default { 'Savings are estimated against your current pricing model.' } + } + if ($script:RIContractNote) { $script:RIContractNote.Text = $contractNote } + if ($script:SPContractNote) { $script:SPContractNote.Text = $contractNote } + + # RI grid - Advisor RI recs + Reservation API recs + $riRows = @() + foreach ($rec in $riRecs) { + $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null } + $riRows += [PSCustomObject]@{ + Subscription = $rec.Subscription + Resource = $rec.ResourceName + 'Resource Type' = $rec.ResourceType + Impact = $rec.Impact + Problem = $rec.Problem + Solution = $rec.Solution + Term = if ($rec.Term) { $rec.Term } else { '-' } + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With RI (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' } + 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' } + } + } + foreach ($rr in $d.Reservations.ReservationRecommendations) { + $riRows += [PSCustomObject]@{ + Subscription = '-' + Resource = if ($rr.SKU) { $rr.SKU } else { $rr.ResourceType } + 'Resource Type' = $rr.ResourceType + Impact = 'High' + Problem = "$($rr.RecommendedQty) x $($rr.ResourceType) at PAYG rates" + Solution = "Purchase $($rr.RecommendedQty) reserved instance(s) ($($rr.Term))" + Term = if ($rr.Term) { $rr.Term } else { '-' } + 'Actual (MTD)' = '-' + 'Forecast' = if ($rr.CostWithoutRI) { "$currency$($rr.CostWithoutRI.ToString('N2'))" } else { '-' } + 'With RI (Mo.)' = if ($rr.CostWithRI) { "$currency$($rr.CostWithRI.ToString('N2'))" } else { '-' } + 'Annual Savings' = if ($rr.NetSavings) { "$currency$($rr.NetSavings.ToString('N2'))" } else { '-' } + } + } + if ($riRows.Count -eq 0) { + $script:RIGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Reserved Instance recommendations at this time.' }) + } else { + $script:RIGrid.ItemsSource = @($riRows) + } + + # SP grid + $spRows = @() + foreach ($rec in $spRecs) { + $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null } + $spRows += [PSCustomObject]@{ + Subscription = $rec.Subscription + Resource = $rec.ResourceName + 'Resource Type' = $rec.ResourceType + Impact = $rec.Impact + Problem = $rec.Problem + Solution = $rec.Solution + Term = if ($rec.Term) { $rec.Term } else { '-' } + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With SP (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' } + 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' } + } + } + if ($spRows.Count -eq 0) { + $script:SPGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Savings Plan recommendations at this time.' }) + } else { + $script:SPGrid.ItemsSource = @($spRows) + } + } else { + $script:RIGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Reserved Instance recommendations at this time.' }) + $script:SPGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Savings Plan recommendations at this time.' }) + } + + # Advisor + if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) { + $script:AdvisorCountText.Text = $d.Optimization.TotalCount.ToString() + $script:AdvisorSavingsText.Text = "Est. $currency$($d.Optimization.EstimatedAnnualSavings.ToString('N2'))/yr" + + $advRows = @() + foreach ($rec in $d.Optimization.Recommendations) { + $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType + $actual = if ($rc) { $rc.Actual } else { $null } + $forecast = if ($rc) { $rc.Forecast } else { $null } + $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null } + $advRows += [PSCustomObject]@{ + Category = $rec.Category + Subscription = $rec.Subscription + Impact = $rec.Impact + Resource = $rec.ResourceName + Problem = $rec.Problem + Solution = $rec.Solution + 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' } + 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' } + 'With Fix (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' } + 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' } + } + } + $script:AdvisorGrid.ItemsSource = @($advRows) + } else { + $script:AdvisorCountText.Text = '0' + $script:AdvisorSavingsText.Text = "$currency" + "0.00/yr" + $script:AdvisorGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Advisor cost optimization recommendations at this time. This is normal for well-optimized or small environments.' }) + } +} + +function Populate-GuidanceTab { + $d = $script:scanData + + # Currency helper + $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { + Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency + } else { '$' } + + # ===================================================================== + # HELPER: Add a rich text line to a StackPanel + # ===================================================================== + function Add-GuidanceLine { + param( + [System.Windows.Controls.StackPanel]$Panel, + [string]$Icon, # Emoji-style prefix e.g. [!] or checkmark + [string]$Bold, # Bold portion + [string]$Normal, # Normal text after bold + [string]$Color = '#444', + [double]$FontSize = 12.5, + [double]$BottomMargin = 6 + ) + $tb = [System.Windows.Controls.TextBlock]::new() + $tb.TextWrapping = 'Wrap' + $tb.FontSize = $FontSize + $tb.Margin = [System.Windows.Thickness]::new(0, 0, 0, $BottomMargin) + + if ($Icon) { + $iconRun = [System.Windows.Documents.Run]::new("$Icon ") + $iconRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($Color) + $iconRun.FontWeight = 'Bold' + $tb.Inlines.Add($iconRun) | Out-Null + } + if ($Bold) { + $boldRun = [System.Windows.Documents.Run]::new($Bold) + $boldRun.FontWeight = 'Bold' + $boldRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222') + $tb.Inlines.Add($boldRun) | Out-Null + } + if ($Normal) { + $sep = if ($Bold) { ' ' } else { '' } + $normRun = [System.Windows.Documents.Run]::new("$sep$Normal") + $normRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#444') + $tb.Inlines.Add($normRun) | Out-Null + } + $Panel.Children.Add($tb) | Out-Null + } + + # ===================================================================== + # FINOPS MATURITY SCORE (0-100) + # Based on FinOps Foundation Maturity Model + Microsoft CAF + # Categories: Visibility (25), Allocation (20), Budgeting (15), + # Optimization (20), Governance (20) + # ===================================================================== + $score = 0 + $maxScore = 100 + $breakdown = @{} + + # --- Visibility (25 pts) ------------------------------------------- + $visScore = 0 + # Tag coverage: 0-10 pts + if ($d.Tags) { + $visScore += [math]::Min([math]::Floor($d.Tags.TagCoverage / 10), 10) + } + # Cost data available: 5 pts + if ($d.Costs -and $d.Costs.Count -gt 0) { $visScore += 5 } + # Cost trend available: 5 pts + if ($d.CostTrend -and $d.CostTrend.HasData) { $visScore += 5 } + # Resource-level cost visibility: 5 pts + if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { $visScore += 5 } + $breakdown['Visibility'] = [math]::Min($visScore, 25) + $score += $breakdown['Visibility'] + + # --- Allocation (20 pts) ------------------------------------------- + # Weighted per-tag scoring: CostCenter/BusinessUnit matter most for chargeback + $allocScore = 0 + # Weighted tag presence: 0-12 pts + if ($d.Tags -and $d.Tags.TagNames) { + $lcKeys = $d.Tags.TagNames.Keys | ForEach-Object { $_.ToLower() } + $tagWeights = @{ + 'CostCenter' = @{ Weight = 3; Alts = @('costcenter', 'cost-center', 'cost_center', 'cc') } + 'BusinessUnit' = @{ Weight = 3; Alts = @('businessunit', 'bu', 'business-unit', 'department', 'dept') } + 'ApplicationName' = @{ Weight = 2; Alts = @('applicationname', 'application', 'app', 'appname') } + 'WorkloadName' = @{ Weight = 1; Alts = @('workloadname', 'workload', 'workload-name') } + 'OpsTeam' = @{ Weight = 1; Alts = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner') } + 'Criticality' = @{ Weight = 1; Alts = @('criticality', 'sla', 'tier') } + 'DataClassification' = @{ Weight = 1; Alts = @('dataclassification', 'data-classification', 'classification') } + } + foreach ($tag in $tagWeights.Keys) { + $allNames = @($tag.ToLower()) + $tagWeights[$tag].Alts + if ($lcKeys | Where-Object { $_ -in $allNames }) { + $allocScore += $tagWeights[$tag].Weight + } + } + } + # Cost-by-tag data available: 4 pts + if ($d.CostByTag -and -not $d.CostByTag.NoTagsFound -and $d.CostByTag.CostByTag.Count -gt 0) { $allocScore += 4 } + # Cost allocation rules configured: 4 pts + if ($d.Billing -and $d.Billing.CostAllocationRules -and $d.Billing.CostAllocationRules.Count -gt 0) { $allocScore += 4 } + $breakdown['Allocation'] = [math]::Min($allocScore, 20) + $score += $breakdown['Allocation'] + + # --- Budgeting & Forecasting (15 pts) ------------------------------ + $budgetScore = 0 + # Has budgets: 5 pts + if ($d.Budgets -and $d.Budgets.HasData) { $budgetScore += 5 } + # Budget coverage: 0-5 pts + if ($d.Budgets) { + $budgetScore += [math]::Min([math]::Floor($d.Budgets.BudgetCoverage / 20), 5) + } + # No budgets over 100%: 5 pts (or partial credit) + if ($d.Budgets -and $d.Budgets.HasData) { + if ($d.Budgets.OverBudgetCount -eq 0) { $budgetScore += 5 } + elseif ($d.Budgets.AtRiskCount -eq 0) { $budgetScore += 3 } + } + $breakdown['Budgeting'] = [math]::Min($budgetScore, 15) + $score += $breakdown['Budgeting'] + + # --- Optimization (20 pts) ----------------------------------------- + $optScore = 0 + # Commitment utilization > 80%: 5 pts + if ($d.Commitments -and $d.Commitments.HasData) { + if ($d.Commitments.RIAvgUtilization -ge 80) { $optScore += 5 } + elseif ($d.Commitments.RIAvgUtilization -ge 60) { $optScore += 3 } + } else { + # No commitments = no waste, partial credit + $optScore += 2 + } + # Savings realized from commitments: 5 pts + if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) { $optScore += 5 } + # Low Advisor recommendations (fewer = better optimized): 0-5 pts + if ($d.Optimization) { + if ($d.Optimization.TotalCount -eq 0) { $optScore += 5 } + elseif ($d.Optimization.TotalCount -le 3) { $optScore += 3 } + elseif ($d.Optimization.TotalCount -le 10) { $optScore += 1 } + } else { $optScore += 2 } + # Few orphaned resources: 5 pts + if ($d.Orphans) { + $orphanTotal = if ($d.Orphans.TotalCount) { $d.Orphans.TotalCount } else { 0 } + if ($orphanTotal -eq 0) { $optScore += 5 } + elseif ($orphanTotal -le 3) { $optScore += 3 } + elseif ($orphanTotal -le 10) { $optScore += 1 } + } else { $optScore += 2 } + $breakdown['Optimization'] = [math]::Min($optScore, 20) + $score += $breakdown['Optimization'] + + # --- Governance (20 pts) ------------------------------------------- + $govScore = 0 + # Has Azure policies: 5 pts + if ($d.PolicyInv -and $d.PolicyInv.AssignmentCount -gt 0) { $govScore += 5 } + # FinOps policies coverage: 0-5 pts + if ($d.PolicyRecs) { + $policyPct = if ($d.PolicyRecs.Analysis.Count -gt 0) { + [math]::Round(($d.PolicyRecs.Assigned.Count / $d.PolicyRecs.Analysis.Count) * 100, 0) + } else { 0 } + $govScore += [math]::Min([math]::Floor($policyPct / 20), 5) + } + # Policy compliance > 80%: 5 pts + if ($d.PolicyInv -and $d.PolicyInv.CompliancePct -ge 80) { $govScore += 5 } + elseif ($d.PolicyInv -and $d.PolicyInv.CompliancePct -ge 50) { $govScore += 3 } + # Has management group hierarchy: 5 pts + if ($d.Hierarchy -and $d.Hierarchy.RootGroup) { $govScore += 5 } + elseif ($d.Hierarchy -and $d.Hierarchy.FlatSubs) { $govScore += 2 } + $breakdown['Governance'] = [math]::Min($govScore, 20) + $score += $breakdown['Governance'] + + $score = [math]::Min($score, $maxScore) + + # Grade label + $grade = switch ($true) { + ($score -ge 85) { 'Excellent'; break } + ($score -ge 70) { 'Good'; break } + ($score -ge 50) { 'Developing'; break } + ($score -ge 30) { 'Foundational'; break } + default { 'Getting Started' } + } + + $gradeColor = switch ($true) { + ($score -ge 85) { '#107C10'; break } + ($score -ge 70) { '#0078D4'; break } + ($score -ge 50) { '#8764B8'; break } + ($score -ge 30) { '#FF8C00'; break } + default { '#D13438' } + } + + # Store computed score on scan data so Export-ScanReport can reuse it + $d | Add-Member -NotePropertyName 'MaturityScore' -NotePropertyValue $score -Force + $d | Add-Member -NotePropertyName 'MaturityBreakdown' -NotePropertyValue $breakdown -Force + $d | Add-Member -NotePropertyName 'MaturityGrade' -NotePropertyValue $grade -Force + $d | Add-Member -NotePropertyName 'MaturityGradeColor' -NotePropertyValue $gradeColor -Force + + # ===================================================================== + # RENDER SCORE CARD + # ===================================================================== + $script:GuidanceScorePanel.Children.Clear() + + # Score card container + $scoreCard = [System.Windows.Controls.Border]::new() + $scoreCard.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#F8F9FA') + $scoreCard.CornerRadius = [System.Windows.CornerRadius]::new(8) + $scoreCard.Padding = [System.Windows.Thickness]::new(24) + $scoreCard.Margin = [System.Windows.Thickness]::new(0, 10, 0, 10) + $scoreCard.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#E0E0E0') + $scoreCard.BorderThickness = [System.Windows.Thickness]::new(1) + + $scoreStack = [System.Windows.Controls.StackPanel]::new() + + # Title + $titleTb = [System.Windows.Controls.TextBlock]::new() + $titleTb.FontSize = 18 + $titleTb.FontWeight = 'SemiBold' + $titleTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#333') + $titleTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 12) + $titleTb.Inlines.Add([System.Windows.Documents.Run]::new('FinOps Maturity Score: ')) | Out-Null + $scoreRun = [System.Windows.Documents.Run]::new("$score / $maxScore") + $scoreRun.FontSize = 24 + $scoreRun.FontWeight = 'Bold' + $scoreRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($gradeColor) + $titleTb.Inlines.Add($scoreRun) | Out-Null + $gradeRun = [System.Windows.Documents.Run]::new(" ($grade)") + $gradeRun.FontSize = 16 + $gradeRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($gradeColor) + $titleTb.Inlines.Add($gradeRun) | Out-Null + $scoreStack.Children.Add($titleTb) | Out-Null + + # Methodology note + $methodTb = [System.Windows.Controls.TextBlock]::new() + $methodTb.Text = 'Score based on FinOps Foundation Maturity Model and Microsoft Cloud Adoption Framework. Categories: Visibility (25), Allocation (20), Budgeting (15), Optimization (20), Governance (20).' + $methodTb.TextWrapping = 'Wrap' + $methodTb.FontSize = 11 + $methodTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#888') + $methodTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 12) + $scoreStack.Children.Add($methodTb) | Out-Null + + # Category breakdown in a horizontal WrapPanel + $catPanel = [System.Windows.Controls.WrapPanel]::new() + $catColors = @{ + 'Visibility' = '#0078D4' + 'Allocation' = '#005A9E' + 'Budgeting' = '#8764B8' + 'Optimization' = '#107C10' + 'Governance' = '#D83B01' + } + $catMax = @{ 'Visibility' = 25; 'Allocation' = 20; 'Budgeting' = 15; 'Optimization' = 20; 'Governance' = 20 } + foreach ($cat in @('Visibility', 'Allocation', 'Budgeting', 'Optimization', 'Governance')) { + $catBorder = [System.Windows.Controls.Border]::new() + $catBorder.Background = [System.Windows.Media.Brushes]::White + $catBorder.CornerRadius = [System.Windows.CornerRadius]::new(4) + $catBorder.Padding = [System.Windows.Thickness]::new(14, 8, 14, 8) + $catBorder.Margin = [System.Windows.Thickness]::new(0, 0, 10, 6) + $catBorder.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#DDD') + $catBorder.BorderThickness = [System.Windows.Thickness]::new(1) + + $catTb = [System.Windows.Controls.TextBlock]::new() + $catTb.FontSize = 12 + $nameRun = [System.Windows.Documents.Run]::new("$cat ") + $nameRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#666') + $catTb.Inlines.Add($nameRun) | Out-Null + + $valRun = [System.Windows.Documents.Run]::new("$($breakdown[$cat]) / $($catMax[$cat])") + $valRun.FontWeight = 'Bold' + $valRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($catColors[$cat]) + $catTb.Inlines.Add($valRun) | Out-Null + + $catBorder.Child = $catTb + $catPanel.Children.Add($catBorder) | Out-Null + } + $scoreStack.Children.Add($catPanel) | Out-Null + + $scoreCard.Child = $scoreStack + $script:GuidanceScorePanel.Children.Add($scoreCard) | Out-Null + + # ===================================================================== + # PRIORITIZED ACTION PLAN + # Build a list of actions sorted by impact, with priority numbering + # ===================================================================== + $script:ActionPlanPanel.Children.Clear() + $actions = [System.Collections.Generic.List[PSCustomObject]]::new() + + # --- Critical: Tag coverage --- + if ($d.Tags -and $d.Tags.TagCoverage -lt 50) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 1; Impact = 'Critical'; Category = 'Allocation' + Title = "Increase tag coverage from $($d.Tags.TagCoverage)% to 80%+" + Detail = 'Untagged resources cannot be allocated to business units. Use Azure Policy to enforce tagging at resource creation. Start with CostCenter, Environment, and Application tags.' + }) + } elseif ($d.Tags -and $d.Tags.TagCoverage -lt 80) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 2; Impact = 'High'; Category = 'Allocation' + Title = "Improve tag coverage from $($d.Tags.TagCoverage)% to 80%+" + Detail = 'Good progress on tagging. Focus on untagged resources using Azure Policy tag inheritance and the Deploy Missing Tags feature on the Tags tab.' + }) + } + + # --- Critical: No budgets --- + if (-not $d.Budgets -or -not $d.Budgets.HasData) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 1; Impact = 'Critical'; Category = 'Budgeting' + Title = 'Set up Azure Budgets with alert thresholds' + Detail = 'No budgets detected. Create budgets at the subscription level with 50%, 75%, 90%, and 100% alert thresholds. Use action groups to notify finance and engineering teams.' + }) + } elseif ($d.Budgets.BudgetCoverage -lt 50) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 2; Impact = 'High'; Category = 'Budgeting' + Title = "Expand budget coverage from $($d.Budgets.BudgetCoverage)% to 100%" + Detail = "Only $($d.Budgets.SubsWithBudget) of $($d.Budgets.SubsWithBudget + $d.Budgets.SubsWithoutBudget) subscriptions have budgets. Every production subscription should have an Azure Budget." + }) + } + + # --- High: Over-budget subscriptions --- + if ($d.Budgets -and $d.Budgets.OverBudgetCount -gt 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 1; Impact = 'Critical'; Category = 'Budgeting' + Title = "$($d.Budgets.OverBudgetCount) subscription(s) are over budget" + Detail = 'Investigate the over-budget subscriptions on the Overview tab. Check for unexpected scaling events, new resource deployments, or pricing changes.' + }) + } + + # --- High: Missing required tags --- + if ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -gt 0) { + $names = ($d.TagRecs.MissingRequired | ForEach-Object { $_.TagName }) -join ', ' + [void]$actions.Add([PSCustomObject]@{ + Priority = 2; Impact = 'High'; Category = 'Allocation' + Title = "Deploy missing required tags: $names" + Detail = 'Microsoft Cloud Adoption Framework requires these tags for chargeback/showback. Use the Tags tab to deploy them to subscriptions or resource groups.' + }) + } + + # --- High: No FinOps policies --- + if ($d.PolicyRecs -and $d.PolicyRecs.Missing.Count -gt 0) { + $missingCount = $d.PolicyRecs.Missing.Count + $totalPolicies = $d.PolicyRecs.Analysis.Count + [void]$actions.Add([PSCustomObject]@{ + Priority = 2; Impact = 'High'; Category = 'Governance' + Title = "Deploy $missingCount of $totalPolicies recommended FinOps policies" + Detail = 'Azure Policy enforces cost governance at scale. Start with Audit mode to measure impact, then move to Deny for critical policies like allowed VM sizes and required tags. Use the Policy tab to deploy.' + }) + } + + # --- Medium: AHB opportunities --- + if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 3; Impact = 'Medium'; Category = 'Optimization' + Title = "Enable Azure Hybrid Benefit on $($d.AHB.TotalOpportunities) resource(s)" + Detail = 'If you have existing Windows Server or SQL Server licenses with Software Assurance, AHB saves 40-85% on compute. This is free money with no architectural changes.' + }) + } + + # --- Medium: Advisor recommendations --- + if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) { + $estSavings = $d.Optimization.EstimatedAnnualSavings.ToString('N2') + [void]$actions.Add([PSCustomObject]@{ + Priority = 3; Impact = 'Medium'; Category = 'Optimization' + Title = "$($d.Optimization.TotalCount) Advisor cost recommendations (est. $currency$estSavings/yr)" + Detail = 'Review Azure Advisor recommendations on the Optimization tab. Common quick wins: rightsize VMs, delete unused resources, shut down dev/test outside business hours.' + }) + } + + # --- Medium: Orphaned resources --- + if ($d.Orphans) { + $orphanTotal = if ($d.Orphans.TotalCount) { $d.Orphans.TotalCount } else { 0 } + if ($orphanTotal -gt 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 3; Impact = 'Medium'; Category = 'Optimization' + Title = "Clean up $orphanTotal orphaned/idle resource(s)" + Detail = 'Orphaned disks, unattached IPs, deallocated VMs, and empty App Service Plans cost money but serve no purpose. Review on the Optimization tab.' + }) + } + } + + # --- Medium: Reservation/SP advice --- + if ($d.Reservations -and ($d.Reservations.TotalAdvisorCount + $d.Reservations.TotalReservationCount) -gt 0) { + $riSavings = $d.Reservations.EstimatedAnnualSavings.ToString('N2') + [void]$actions.Add([PSCustomObject]@{ + Priority = 3; Impact = 'Medium'; Category = 'Optimization' + Title = "Evaluate RI/Savings Plan opportunities (est. $currency$riSavings/yr)" + Detail = 'For steady-state workloads, Reserved Instances save 30-72% vs. pay-as-you-go. Savings Plans offer flexibility across VM families. Start with 1-year terms to reduce risk.' + }) + } + + # --- Lower: Commitment utilization --- + if ($d.Commitments -and $d.Commitments.HasData -and $d.Commitments.UnderutilizedRIs.Count -gt 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 4; Impact = 'Low'; Category = 'Optimization' + Title = "$($d.Commitments.UnderutilizedRIs.Count) underutilized reservation(s) (below 80%)" + Detail = 'Exchange or refund underperforming reservations. Azure allows one-time exchanges to better-fitting SKUs or regions. Target 80%+ utilization on all commitments.' + }) + } + + # --- No MG hierarchy = flat org --- + if (-not $d.Hierarchy -or -not $d.Hierarchy.RootGroup) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 4; Impact = 'Low'; Category = 'Governance' + Title = 'Set up Management Group hierarchy' + Detail = 'Management Groups enable policy inheritance and cost rollup at the organizational level. Structure as: Tenant Root > Platform / Landing Zones > Production / Dev / Sandbox.' + }) + } + + # --- Positive: Add encouragement for things done well --- + if ($d.Budgets -and $d.Budgets.HasData -and $d.Budgets.BudgetCoverage -ge 80) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 10; Impact = 'Strength'; Category = 'Budgeting' + Title = "Budget coverage is $($d.Budgets.BudgetCoverage)% - well governed" + Detail = 'Consider adding action groups that auto-scale down or shut off dev resources when budgets hit 90%.' + }) + } + if ($d.Tags -and $d.Tags.TagCoverage -ge 80) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 10; Impact = 'Strength'; Category = 'Allocation' + Title = "Tag coverage at $($d.Tags.TagCoverage)% - strong cost allocation" + Detail = 'Next step: implement tag-based cost allocation rules in Cost Management to automatically distribute shared costs to business units.' + }) + } + if ($d.PolicyInv -and $d.PolicyInv.AssignmentCount -gt 5) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 10; Impact = 'Strength'; Category = 'Governance' + Title = "$($d.PolicyInv.AssignmentCount) policies in place - governance foundation established" + Detail = 'Review compliance % on the Policy tab. Move Audit-mode policies to Deny for critical rules once compliance is above 90%.' + }) + } + if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 10; Impact = 'Strength'; Category = 'Optimization' + Title = "Already saving $currency$($d.Savings.TotalMonthly.ToString('N2'))/mo from commitments" + Detail = 'Great foundation. Monitor utilization monthly and consider expanding coverage as workloads stabilize.' + }) + } + + # Fall back if nothing + if ($actions.Count -eq 0) { + [void]$actions.Add([PSCustomObject]@{ + Priority = 5; Impact = 'Info'; Category = 'General' + Title = 'Run a full scan with Cost Management Reader permissions for detailed recommendations' + Detail = 'The scanner needs cost and policy data to generate specific actions. Ensure the account has Reader + Cost Management Reader at the management group or subscription scope.' + }) + } + + # Sort: Critical first, Strength last + $sortedActions = @($actions | Sort-Object Priority, Category) + $impactToColor = @{ + Critical = '#D13438'; High = '#FF8C00'; Medium = '#0078D4' + Low = '#666'; Info = '#888'; Strength = '#107C10' + } + + $subtitle = "Based on your scan results, here are $($sortedActions.Count) recommendations in priority order." + if ($score -ge 70) { $subtitle += ' Your environment is in good shape - focus on the refinements below.' } + elseif ($score -ge 50) { $subtitle += ' You have a solid foundation - the items below will accelerate FinOps maturity.' } + else { $subtitle += ' Start with the Critical and High-impact items to build your FinOps foundation.' } + $script:ActionPlanSubtitle.Text = $subtitle + + $actionNum = 0 + foreach ($a in $sortedActions) { + $actionNum++ + $color = if ($impactToColor.ContainsKey($a.Impact)) { $impactToColor[$a.Impact] } else { '#444' } + + $actionBorder = [System.Windows.Controls.Border]::new() + $actionBorder.Background = [System.Windows.Media.Brushes]::White + $actionBorder.CornerRadius = [System.Windows.CornerRadius]::new(4) + $actionBorder.Padding = [System.Windows.Thickness]::new(14, 10, 14, 10) + $actionBorder.Margin = [System.Windows.Thickness]::new(0, 0, 0, 6) + $actionBorder.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#E8E8E8') + $actionBorder.BorderThickness = [System.Windows.Thickness]::new(1) + + $actionStack = [System.Windows.Controls.StackPanel]::new() + + # Title line: #1 [Critical] Title + $titleLine = [System.Windows.Controls.TextBlock]::new() + $titleLine.TextWrapping = 'Wrap' + $titleLine.FontSize = 13 + $titleLine.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4) + + $numRun = [System.Windows.Documents.Run]::new("#$actionNum ") + $numRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#999') + $numRun.FontWeight = 'Bold' + $titleLine.Inlines.Add($numRun) | Out-Null + + $tagRun = [System.Windows.Documents.Run]::new("[$($a.Impact)] ") + $tagRun.FontWeight = 'Bold' + $tagRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($color) + $titleLine.Inlines.Add($tagRun) | Out-Null + + $titleRun = [System.Windows.Documents.Run]::new($a.Title) + $titleRun.FontWeight = 'SemiBold' + $titleRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222') + $titleLine.Inlines.Add($titleRun) | Out-Null + + $actionStack.Children.Add($titleLine) | Out-Null + + # Detail line + $detailTb = [System.Windows.Controls.TextBlock]::new() + $detailTb.Text = $a.Detail + $detailTb.TextWrapping = 'Wrap' + $detailTb.FontSize = 12 + $detailTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#555') + $actionStack.Children.Add($detailTb) | Out-Null + + $actionBorder.Child = $actionStack + $script:ActionPlanPanel.Children.Add($actionBorder) | Out-Null + } + + # ===================================================================== + # UNDERSTAND PILLAR (rich formatted) + # ===================================================================== + $script:UnderstandPanel.Children.Clear() + if ($d.Tags) { + if ($d.Tags.TagCoverage -lt 50) { + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'CRITICAL:' -Normal "Only $($d.Tags.TagCoverage)% of resources are tagged. Target 80%+ for meaningful cost allocation. Use Azure Policy to auto-apply tags at resource creation." -Color '#D13438' + } elseif ($d.Tags.TagCoverage -lt 80) { + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'Tag coverage:' -Normal "$($d.Tags.TagCoverage)%. Good progress. Focus on the remaining untagged resources using tag inheritance policies." -Color '#FF8C00' + } else { + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '+' -Bold 'Tag coverage:' -Normal "$($d.Tags.TagCoverage)% - strong foundation for showback/chargeback." -Color '#107C10' + } + } + if ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -gt 0) { + $names = ($d.TagRecs.MissingRequired | ForEach-Object { $_.TagName }) -join ', ' + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'Missing required tags:' -Normal "$names. These are essential for cost allocation per Microsoft CAF." -Color '#D13438' + } + if ($d.CostByTag -and $d.CostByTag.NoTagsFound) { + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'No cost-allocation tags found.' -Normal 'All spend is unallocated. Finance teams cannot attribute costs to business units without CostCenter, Environment, or Application tags.' -Color '#D13438' + } + if ($d.Tags -and $d.Tags.TagCoverage -ge 80 -and ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -eq 0)) { + Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '+' -Bold 'Cost visibility is strong.' -Normal 'Tags are well-deployed and CAF-compliant. Consider implementing tag-based cost allocation rules for shared resources.' -Color '#107C10' + } + + # ===================================================================== + # QUANTIFY PILLAR (rich formatted) + # ===================================================================== + $script:QuantifyPanel.Children.Clear() + $totalActual = 0; $totalForecast = 0 + if ($d.Costs) { + foreach ($entry in $d.Costs.GetEnumerator()) { + $totalActual += $entry.Value.Actual + $totalForecast += $entry.Value.Forecast + } + } + $dayOfMonth = (Get-Date).Day + $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month) + $pctMonthElapsed = [math]::Round(($dayOfMonth / $daysInMonth) * 100, 0) + + if ($dayOfMonth -le 3) { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Day $dayOfMonth of billing period ($pctMonthElapsed% elapsed)." -Normal 'Forecasts are less reliable this early. Check back after day 7 for more accurate projections.' -Color '#0078D4' + } elseif ($dayOfMonth -le 7) { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Early in billing period (day $dayOfMonth)." -Normal 'Forecast accuracy improves after week 1.' -Color '#0078D4' + } else { + if ($totalActual -gt 0 -and $totalForecast -gt $totalActual * 1.2) { + $increase = [math]::Round((($totalForecast - $totalActual) / $totalActual) * 100, 0) + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '!' -Bold "Forecast is $increase% above MTD spend." -Normal "$currency$($totalForecast.ToString('N2')) projected vs $currency$($totalActual.ToString('N2')) actual on day $dayOfMonth/$daysInMonth. Review scaling patterns and set budget alerts." -Color '#FF8C00' + } elseif ($totalForecast -gt 0) { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '+' -Bold 'Costs appear stable.' -Normal "Forecast $currency$($totalForecast.ToString('N2')) is within 20% of MTD spend on day $dayOfMonth/$daysInMonth." -Color '#107C10' + } + } + if ($totalForecast -gt 0) { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Current forecast:" -Normal "$currency$($totalForecast.ToString('N2')) for the full month (MTD actual: $currency$($totalActual.ToString('N2')))." -Color '#0078D4' + } + if (-not $d.Budgets -or -not $d.Budgets.HasData) { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '!' -Bold 'No Azure Budgets detected.' -Normal 'Set budgets at subscription or resource group level with 50%, 75%, 90%, 100% thresholds. Use action groups for email + auto-shutdown.' -Color '#D13438' + } else { + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '+' -Bold "Budget coverage: $($d.Budgets.BudgetCoverage)%." -Normal "$($d.Budgets.SubsWithBudget) subscription(s) have budgets configured." -Color '#107C10' + } + Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '>' -Bold 'TIP:' -Normal 'Use Cost Management Exports to send daily/monthly cost data to a Storage Account for Power BI dashboards and FinOps reporting.' -Color '#8764B8' + + # ===================================================================== + # OPTIMIZE PILLAR (rich formatted) + # ===================================================================== + $script:OptimizePanel.Children.Clear() + if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) { + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '$' -Bold "$($d.AHB.TotalOpportunities) AHB opportunity(s)." -Normal 'Apply Azure Hybrid Benefit to save 40-85% if you have existing Windows/SQL licenses with Software Assurance. Zero architectural change required.' -Color '#107C10' + } + if ($d.Reservations -and ($d.Reservations.TotalAdvisorCount + $d.Reservations.TotalReservationCount) -gt 0) { + $riSavings = $d.Reservations.EstimatedAnnualSavings.ToString('N2') + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '$' -Bold "RI/SP opportunities: est. $currency$riSavings/yr savings." -Normal 'For steady-state workloads, commit to 1-year terms first to reduce risk. Savings Plans offer VM family flexibility.' -Color '#107C10' + } + if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) { + foreach ($cat in $d.Optimization.ByCategory) { + $catSavings = $cat.TotalSavings.ToString('N2') + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '>' -Bold "$($cat.Count) $($cat.Category) recommendation(s)" -Normal "(est. $currency$catSavings/yr). Review details on the Optimization tab." -Color '#0078D4' + } + } + if ($d.Contract) { + $type = $d.Contract[0].AgreementType + if ($type -eq 'MicrosoftOnlineServicesProgram') { + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '!' -Bold 'Pay-As-You-Go (PAYGO) account detected.' -Normal 'Consider an Enterprise Agreement (EA) or Microsoft Customer Agreement (MCA) for volume discounts, negotiated rates, and better cost management tooling.' -Color '#FF8C00' + } + } + if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) { + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '+' -Bold "Already saving $currency$($d.Savings.TotalMonthly.ToString('N2'))/mo" -Normal 'from existing reservations, savings plans, and/or AHB. Monitor utilization monthly.' -Color '#107C10' + } + if ($script:OptimizePanel.Children.Count -eq 0) { + Add-GuidanceLine -Panel $script:OptimizePanel -Icon '+' -Bold 'No major optimization gaps detected.' -Normal 'Continue monitoring Azure Advisor and Cost Management for new opportunities.' -Color '#107C10' + } + + # ===================================================================== + # PERSONAS - FinOps Foundation defined roles + # ===================================================================== + $script:PersonasPanel.Children.Clear() + $personas = @( + @{ Role = 'FinOps Practitioner'; Desc = 'Drives the FinOps practice: runs cost reviews, manages tooling, builds reports, educates teams. Often the first hire for a FinOps program.'; When = 'Always needed' } + @{ Role = 'Engineering / DevOps Lead'; Desc = 'Implements rightsizing, AHB, auto-shutdown, and tagging at the resource level. Owns technical optimization actions.'; When = 'Always needed' } + @{ Role = 'Finance / Procurement'; Desc = 'Manages budgets, forecasts, commitment purchases (RIs/SPs), and licensing agreements. Owns the commercial relationship.'; When = 'Always needed' } + @{ Role = 'Executive Sponsor (VP/Director)'; Desc = 'Champions FinOps across the organization, breaks down silos between finance and engineering, approves commitment purchases.'; When = 'Critical for organizational buy-in' } + @{ Role = 'Cloud Architect'; Desc = 'Designs cost-efficient architectures, evaluates PaaS vs IaaS trade-offs, and ensures workloads are right-sized from the start.'; When = 'During design reviews and migrations' } + @{ Role = 'Business Unit Owners'; Desc = 'Consume cost reports (showback/chargeback), validate tag accuracy, and make build-vs-buy decisions for their teams.'; When = 'For cost allocation and accountability' } + ) + foreach ($p in $personas) { + $personaTb = [System.Windows.Controls.TextBlock]::new() + $personaTb.TextWrapping = 'Wrap' + $personaTb.FontSize = 12.5 + $personaTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 8) + + $roleRun = [System.Windows.Documents.Run]::new("$($p.Role): ") + $roleRun.FontWeight = 'Bold' + $roleRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222') + $personaTb.Inlines.Add($roleRun) | Out-Null + + $descRun = [System.Windows.Documents.Run]::new($p.Desc) + $descRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#444') + $personaTb.Inlines.Add($descRun) | Out-Null + + $whenRun = [System.Windows.Documents.Run]::new(" ($($p.When))") + $whenRun.FontStyle = 'Italic' + $whenRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#888') + $personaTb.Inlines.Add($whenRun) | Out-Null + + $script:PersonasPanel.Children.Add($personaTb) | Out-Null + } +} + +#----------------------------------------------------------------------- +# COST TREND BAR CHART (pure WPF Canvas drawing) +#----------------------------------------------------------------------- +function Populate-TrendChart { + $d = $script:scanData.CostTrend + if (-not $d -or -not $d.HasData) { + $script:TrendNote.Text = "No cost trend data available." + return + } + + # Populate subscription dropdown (only on first call) + if ($script:TrendSubSelector.Items.Count -eq 0) { + $script:TrendSubSelector.Items.Add('All Subscriptions') | Out-Null + if ($d.BySubscription -and $d.BySubscription.Count -gt 0) { + foreach ($sub in $script:scanData.Auth.Subscriptions) { + if ($d.BySubscription.ContainsKey($sub.Id)) { + $script:TrendSubSelector.Items.Add($sub.Name) | Out-Null + } + } + } + $script:TrendSubSelector.SelectedIndex = 0 + } + + Draw-TrendChart -Months $d.Months +} + +function Draw-TrendChart { + param([object[]]$Months) + + $canvas = $script:TrendChart + $canvas.Children.Clear() + $script:TrendNote.Text = '' + + if (-not $Months -or $Months.Count -eq 0) { + $script:TrendNote.Text = 'No cost data for selected subscription.' + return + } + + $months = $Months + + $currency = if ($months[0].Currency) { Get-CurrencySymbol -Code $months[0].Currency } else { '$' } + $maxCost = ($months | Measure-Object -Property Cost -Maximum).Maximum + if ($maxCost -le 0) { $maxCost = 1 } + + $canvasW = 900 + $canvasH = 200 + $barGap = 12 + $labelH = 30 + $chartH = $canvasH - $labelH + $barCount = $months.Count + $barW = [math]::Floor(($canvasW - ($barGap * ($barCount + 1))) / $barCount) + if ($barW -gt 120) { $barW = 120 } + + $colors = @('#0078D4', '#005A9E', '#0063B1', '#2B88D8', '#106EBE', '#004578') + + for ($i = 0; $i -lt $barCount; $i++) { + $m = $months[$i] + $barH = [math]::Max(([math]::Round(($m.Cost / $maxCost) * $chartH, 0)), 2) + $x = $barGap + ($i * ($barW + $barGap)) + $y = $chartH - $barH + + # Bar rectangle + $rect = [System.Windows.Shapes.Rectangle]::new() + $rect.Width = $barW + $rect.Height = $barH + $rect.Fill = [System.Windows.Media.BrushConverter]::new().ConvertFromString($colors[$i % $colors.Count]) + $rect.RadiusX = 3 + $rect.RadiusY = 3 + [System.Windows.Controls.Canvas]::SetLeft($rect, $x) + [System.Windows.Controls.Canvas]::SetTop($rect, $y) + $canvas.Children.Add($rect) | Out-Null + + # Cost label above bar (or inside bar if it would clip above canvas) + $costLabel = [System.Windows.Controls.TextBlock]::new() + $costLabel.Text = "$currency$($m.Cost.ToString('N0'))" + $costLabel.FontSize = 10 + $costLabel.TextAlignment = 'Center' + $costLabel.Width = $barW + $labelTop = $y - 16 + if ($labelTop -lt 0) { + # Place label inside the top of the bar with white text + $labelTop = $y + 4 + $costLabel.Foreground = [System.Windows.Media.Brushes]::White + $costLabel.FontWeight = 'SemiBold' + } else { + $costLabel.Foreground = [System.Windows.Media.Brushes]::Gray + } + [System.Windows.Controls.Canvas]::SetLeft($costLabel, $x) + [System.Windows.Controls.Canvas]::SetTop($costLabel, $labelTop) + $canvas.Children.Add($costLabel) | Out-Null + + # Month label below bar + $monthLabel = [System.Windows.Controls.TextBlock]::new() + $monthLabel.Text = $m.Month + $monthLabel.FontSize = 10 + $monthLabel.FontWeight = 'SemiBold' + $monthLabel.Foreground = [System.Windows.Media.Brushes]::DimGray + $monthLabel.TextAlignment = 'Center' + $monthLabel.Width = $barW + [System.Windows.Controls.Canvas]::SetLeft($monthLabel, $x) + [System.Windows.Controls.Canvas]::SetTop($monthLabel, $chartH + 4) + $canvas.Children.Add($monthLabel) | Out-Null + } + + # Trend note + $firstCost = $months[0].Cost + $lastCost = $months[$months.Count - 1].Cost + if ($firstCost -gt 0) { + $changePct = [math]::Round((($lastCost - $firstCost) / $firstCost) * 100, 1) + $direction = if ($changePct -gt 0) { "up" } elseif ($changePct -lt 0) { "down" } else { "flat" } + $script:TrendNote.Text = "6-month trend: $currency$($firstCost.ToString('N2')) -> $currency$($lastCost.ToString('N2')) ($direction $([math]::Abs($changePct))%)" + } else { + $script:TrendNote.Text = "" + } +} + +# Trend subscription dropdown handler +$script:TrendSubSelector.Add_SelectionChanged({ + $d = $script:scanData.CostTrend + if (-not $d -or -not $d.HasData) { return } + + $selectedIdx = $script:TrendSubSelector.SelectedIndex + if ($selectedIdx -le 0) { + # All subscriptions + Draw-TrendChart -Months $d.Months + } else { + $selectedName = $script:TrendSubSelector.SelectedItem + $sub = $script:scanData.Auth.Subscriptions | Where-Object { $_.Name -eq $selectedName } | Select-Object -First 1 + if ($sub -and $d.BySubscription -and $d.BySubscription.ContainsKey($sub.Id)) { + Draw-TrendChart -Months $d.BySubscription[$sub.Id] + } else { + Draw-TrendChart -Months @() + } + } +}) + +#----------------------------------------------------------------------- +# TAG DEPLOYMENT UI WIRING +#----------------------------------------------------------------------- +$script:tagDeployCurrentTag = $null +$script:tagDeployScopesLoaded = $false +$script:tagDeployScopes = @() +$script:tagRemoveMode = $false +$script:tagCustomMode = $false + +function Load-TagScopes { + if (-not $script:tagDeployScopesLoaded -and $script:scanData.Auth) { + $script:TagDeployStatus.Text = 'Loading scopes...' + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [action]{}, [System.Windows.Threading.DispatcherPriority]::Background + ) + $script:tagDeployScopes = Get-TagScopes -Subscriptions $script:scanData.Auth.Subscriptions + $script:tagDeployScopesLoaded = $true + $script:TagDeployStatus.Text = '' + } + + $script:TagScopeSelector.Items.Clear() + foreach ($s in $script:tagDeployScopes) { + $script:TagScopeSelector.Items.Add($s.DisplayName) | Out-Null + } + if ($script:tagDeployScopes.Count -gt 0) { + $script:TagScopeSelector.SelectedIndex = 0 + } +} + +function Show-TagDeployPanel { + param([string]$TagName) + + $script:tagDeployCurrentTag = $TagName + $script:tagRemoveMode = $false + $script:tagCustomMode = $false + $script:TagDeployTitle.Text = "Deploy tag: $TagName" + $script:TagDeployStatus.Text = '' + $script:TagValueInput.Text = '' + $script:TagValueInput.Visibility = 'Visible' + $script:TagNameInput.Visibility = 'Collapsed' + $script:TagNameLabel.Visibility = 'Collapsed' + # Show the tag value label + $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput) + if ($valIdx -gt 0) { + $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible' + } + $script:TagDeployButton.Content = 'Deploy Tag' + $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4') + $script:TagDeployPanel.Visibility = 'Visible' + + Load-TagScopes +} + +function Show-CustomTagDeployPanel { + $script:tagDeployCurrentTag = $null + $script:tagRemoveMode = $false + $script:tagCustomMode = $true + $script:TagDeployTitle.Text = "Deploy Custom Tag" + $script:TagDeployStatus.Text = '' + $script:TagNameInput.Text = '' + $script:TagNameInput.Visibility = 'Visible' + $script:TagNameLabel.Visibility = 'Visible' + $script:TagValueInput.Text = '' + $script:TagValueInput.Visibility = 'Visible' + $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput) + if ($valIdx -gt 0) { + $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible' + } + $script:TagDeployButton.Content = 'Deploy Tag' + $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4') + $script:TagDeployPanel.Visibility = 'Visible' + + Load-TagScopes +} + +function Show-TagRemovePanel { + param([string]$TagName) + + $script:tagDeployCurrentTag = $TagName + $script:tagRemoveMode = $true + $script:tagCustomMode = $false + $script:TagDeployTitle.Text = "Remove tag: $TagName" + $script:TagDeployStatus.Text = '' + $script:TagNameInput.Visibility = 'Collapsed' + $script:TagNameLabel.Visibility = 'Collapsed' + + # Show value input as optional filter + $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput) + if ($valIdx -gt 0) { + $script:TagDeployPanel.Child.Children[$valIdx - 1].Text = 'Value Filter (blank = all values):' + $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible' + } + $script:TagValueInput.Text = '' + $script:TagValueInput.Visibility = 'Visible' + $script:TagDeployButton.Content = 'Remove Tag' + $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438') + $script:TagDeployPanel.Visibility = 'Visible' + + Load-TagScopes + + # Insert "All Scopes" entries at the top of the scope selector for mass removal + # One per subscription: removes from the sub + all its RGs in a single click + $allEntries = @() + foreach ($sub in $script:scanData.Auth.Subscriptions) { + $allEntries += [PSCustomObject]@{ + DisplayName = "[ALL] $($sub.Name) (Sub + all RGs)" + SubId = $sub.Id + } + } + # Insert at position 0 so they appear first + for ($i = $allEntries.Count - 1; $i -ge 0; $i--) { + $script:TagScopeSelector.Items.Insert(0, $allEntries[$i].DisplayName) + } + # Track in a script-scoped list so the handler knows which indices are "all" entries + $script:tagRemoveAllEntries = $allEntries + $script:TagScopeSelector.SelectedIndex = 0 +} + +#----------------------------------------------------------------------- +# POLICY TAB POPULATION +#----------------------------------------------------------------------- +function Populate-PolicyTab { + $d = $script:scanData + + # Summary cards + if ($d.PolicyInv) { + $script:PolicyCountText.Text = $d.PolicyInv.AssignmentCount.ToString() + $script:PolicyComplianceText.Text = "$($d.PolicyInv.CompliancePct)%" + $script:PolicyNonCompliantText.Text = $d.PolicyInv.TotalNonCompliant.ToString('N0') + + # Assignment inventory grid with inline Unassign button + $script:PolicyInventoryGrid.AutoGenerateColumns = $false + $script:PolicyInventoryGrid.Columns.Clear() + + foreach ($col in @('Assignment Name','Type','Effect','Enforcement','Origin','Subscription','Scope')) { + $dgCol = [System.Windows.Controls.DataGridTextColumn]::new() + $dgCol.Header = $col + $dgCol.Binding = [System.Windows.Data.Binding]::new($col) + if ($col -in @('Assignment Name','Scope')) { + $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star) + $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock]) + $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap)) + } + $script:PolicyInventoryGrid.Columns.Add($dgCol) + } + + # Unassign button template column + $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new() + $actionCol.Header = 'Action' + $actionCol.Width = 75 + + $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button]) + $cellFactory.SetValue([System.Windows.Controls.Button]::ContentProperty, 'Unassign') + $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('AssignmentIndex')) + $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10) + $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6,1,6,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2,1,2,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand) + $cellFactory.SetValue([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#FDE7E9')) + $cellFactory.SetValue([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438')) + $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1)) + $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ + param($sender, $e) + $idx = [int]$sender.Tag + $assignment = $script:scanData.PolicyInv.Assignments[$idx] + $displayName = $assignment.AssignmentName + $policyDefId = $assignment.PolicyDefId + + # Find ALL assignments with the same PolicyDefId (same policy assigned multiple times) + $matchingAssignments = @($script:scanData.PolicyInv.Assignments | Where-Object { + $_.PolicyDefId -and $policyDefId -and $_.PolicyDefId.ToLower() -eq $policyDefId.ToLower() + }) + + $matchCount = $matchingAssignments.Count + $statusLabel = if ($matchCount -gt 1) { "Removing $matchCount assignments of this policy..." } else { "Removing assignment..." } + + $script:PolicyDeployTitle.Text = "Unassign: $displayName" + $script:PolicyDeployStatus.Text = $statusLabel + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray + $script:PolicyDeployPanel.Visibility = 'Visible' + $script:PolicyScopeSelector.Visibility = 'Collapsed' + $script:PolicyEffectSelector.Visibility = 'Collapsed' + $script:PolicyParamsPanel.Visibility = 'Collapsed' + $script:PolicyRemediateButton.Visibility = 'Collapsed' + foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) { + $parent = $ctrl.Parent + if ($parent) { + $ctrlIdx = $parent.Children.IndexOf($ctrl) + if ($ctrlIdx -gt 0) { $parent.Children[$ctrlIdx - 1].Visibility = 'Collapsed' } + } + } + $script:PolicyDeployButton.Visibility = 'Collapsed' + + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [System.Windows.Threading.DispatcherPriority]::Render, [action]{}) + + $successCount = 0 + $failMsg = '' + foreach ($ma in $matchingAssignments) { + try { + $result = Remove-PolicyAssignment -AssignmentId $ma.AssignmentId + if ($result.Success) { + $successCount++ + } else { + $failMsg = $result.Message + } + } catch { + $failMsg = $_.Exception.Message + } + } + + if ($successCount -eq $matchCount) { + $script:PolicyDeployStatus.Text = "Unassigned: $displayName ($successCount assignment(s) removed)" + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10') + $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Policy Unassigned'; Detail = "$displayName ($successCount removed)" }) + # Disable all matching buttons in the grid + $sender.Content = 'Removed' + $sender.IsEnabled = $false + } elseif ($successCount -gt 0) { + $script:PolicyDeployStatus.Text = "Partial: $successCount of $matchCount removed. Error: $failMsg" + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01') + } else { + $script:PolicyDeployStatus.Text = "Failed: $failMsg" + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01') + } + $script:PolicyDeployButton.Visibility = 'Visible' + }) + + $cellTemplate = [System.Windows.DataTemplate]::new() + $cellTemplate.VisualTree = $cellFactory + $actionCol.CellTemplate = $cellTemplate + $script:PolicyInventoryGrid.Columns.Add($actionCol) + + $idx = 0 + $invRows = $d.PolicyInv.Assignments | ForEach-Object { + $type = if ($_.PolicyDefId -match '/policySetDefinitions/') { 'Initiative' } else { 'Policy' } + $row = [PSCustomObject]@{ + 'Assignment Name' = $_.AssignmentName + 'Type' = $type + 'Effect' = $_.Effect + 'Enforcement' = $_.EnforcementMode + 'Origin' = $_.Origin + 'Subscription' = $_.Subscription + 'Scope' = if ($_.Scope.Length -gt 60) { '...' + $_.Scope.Substring($_.Scope.Length - 57) } else { $_.Scope } + 'AssignmentIndex' = $idx + } + $idx++ + $row + } + $script:PolicyInventoryGrid.ItemsSource = @($invRows) + + # Per-subscription compliance grid + $compRows = $d.PolicyInv.ComplianceBySubMap.Values | ForEach-Object { + [PSCustomObject]@{ + 'Subscription' = $_.Subscription + 'Compliant' = $_.Compliant + 'Non-Compliant' = $_.NonCompliant + 'Total Evaluated' = $_.TotalResources + 'Compliance %' = if (($_.Compliant + $_.NonCompliant) -gt 0) { + [math]::Round(($_.Compliant / ($_.Compliant + $_.NonCompliant)) * 100, 1).ToString() + '%' + } else { '-' } + } + } + $script:PolicyComplianceGrid.ItemsSource = @($compRows) + } + + # Policy recommendations with inline action buttons + if ($d.PolicyRecs) { + $assignedCount = $d.PolicyRecs.Assigned.Count + $analysisCount = $d.PolicyRecs.Analysis.Count + $script:PolicyRecsCountText.Text = "$assignedCount / $analysisCount" + $script:PolicyRecsComplianceText.Text = "CAF policy coverage: $($d.PolicyRecs.CompliancePct)% ($assignedCount of $analysisCount recommended policies assigned)" + + # Build the policy recs grid with programmatic columns including an Action button + $script:PolicyRecsGrid.AutoGenerateColumns = $false + $script:PolicyRecsGrid.Columns.Clear() + + # Data columns + foreach ($col in @('Policy','Status','Category','Priority','Pillar','Effect','Purpose')) { + $dgCol = [System.Windows.Controls.DataGridTextColumn]::new() + $dgCol.Header = $col + $dgCol.Binding = [System.Windows.Data.Binding]::new($col) + if ($col -eq 'Purpose') { + $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star) + $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock]) + $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap)) + } + $script:PolicyRecsGrid.Columns.Add($dgCol) + } + + # Action button template column + $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new() + $actionCol.Header = 'Action' + $actionCol.Width = 85 + + $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button]) + $cellFactory.SetBinding([System.Windows.Controls.Button]::ContentProperty, [System.Windows.Data.Binding]::new('ActionLabel')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Data.Binding]::new('ActionBg')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Data.Binding]::new('ActionFg')) + $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('PolicyIndex')) + $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10) + $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6,1,6,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2,1,2,1)) + $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand) + $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1)) + $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler]{ + param($sender, $e) + $idx = [int]$sender.Tag + $pol = $script:scanData.PolicyRecs.Analysis[$idx] + if ($pol.Status -eq 'Missing') { + $polParams = if ($pol.Parameters) { $pol.Parameters } else { @() } + Show-PolicyDeployPanel -PolicyDisplayName $pol.DisplayName -PolicyDefId $pol.PolicyDefId -AllowedEffects $pol.AllowedEffects -DefaultEffect $pol.DefaultEffect -Parameters $polParams + } else { + Show-PolicyUnassignPanel -PolicyDisplayName $pol.DisplayName -PolicyDefId $pol.PolicyDefId + } + }) + + $cellTemplate = [System.Windows.DataTemplate]::new() + $cellTemplate.VisualTree = $cellFactory + $actionCol.CellTemplate = $cellTemplate + $script:PolicyRecsGrid.Columns.Add($actionCol) + + # Populate rows with action metadata + $brushConv = [System.Windows.Media.BrushConverter]::new() + $idx = 0 + $recRows = $d.PolicyRecs.Analysis | ForEach-Object { + $isMissing = $_.Status -eq 'Missing' + $row = [PSCustomObject]@{ + 'Policy' = $_.DisplayName + 'Status' = $_.Status + 'Category' = $_.Category + 'Priority' = $_.Priority + 'Pillar' = $_.Pillar + 'Effect' = $_.DefaultEffect + 'Purpose' = $_.Purpose + 'PolicyIndex' = $idx + 'ActionLabel' = if ($isMissing) { 'Deploy' } else { 'Unassign' } + 'ActionBg' = if ($isMissing) { $brushConv.ConvertFromString('#DFF6DD') } else { $brushConv.ConvertFromString('#FDE7E9') } + 'ActionFg' = if ($isMissing) { $brushConv.ConvertFromString('#107C10') } else { $brushConv.ConvertFromString('#D13438') } + } + $idx++ + $row + } + $script:PolicyRecsGrid.ItemsSource = @($recRows) + } +} + +function Show-PolicyDeployPanel { + param( + [string]$PolicyDisplayName, + [string]$PolicyDefId, + [string[]]$AllowedEffects, + [string]$DefaultEffect, + [object[]]$Parameters = @() + ) + + $script:policyDeployCurrentDefId = $PolicyDefId + $script:policyDeployCurrentName = $PolicyDisplayName + $script:policyDeployCurrentParams = $Parameters + $script:policyUnassignMode = $false + $script:PolicyDeployTitle.Text = "Deploy policy: $PolicyDisplayName" + $script:PolicyDeployStatus.Text = '' + $script:PolicyDeployPanel.Visibility = 'Visible' + $script:PolicyDeployButton.Content = 'Deploy Policy' + $script:PolicyDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4') + + # Ensure scope/effect/params are visible (may have been hidden by unassign) + $script:PolicyScopeSelector.Visibility = 'Visible' + $script:PolicyEffectSelector.Visibility = 'Visible' + $script:PolicyParamsPanel.Visibility = 'Visible' + foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) { + $parent = $ctrl.Parent + if ($parent) { + $idx = $parent.Children.IndexOf($ctrl) + if ($idx -gt 0) { $parent.Children[$idx - 1].Visibility = 'Visible' } + } + } + + # Populate effect selector + $script:PolicyEffectSelector.Items.Clear() + foreach ($eff in $AllowedEffects) { + $script:PolicyEffectSelector.Items.Add($eff) | Out-Null + } + # Pre-select default (Audit for safety) + $safeDefault = if ($AllowedEffects -contains 'Audit') { 'Audit' } else { $DefaultEffect } + $idx = [Array]::IndexOf($AllowedEffects, $safeDefault) + $script:PolicyEffectSelector.SelectedIndex = if ($idx -ge 0) { $idx } else { 0 } + + # Build dynamic parameter inputs + $script:PolicyParamsPanel.Children.Clear() + $script:policyParamTextBoxes = @{} + if ($Parameters -and $Parameters.Count -gt 0) { + foreach ($p in $Parameters) { + $lbl = [System.Windows.Controls.TextBlock]::new() + $lbl.Text = "$($p.Label)$(if ($p.Required) { ' *' } else { '' }):" + $lbl.FontSize = 12 + $lbl.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4) + $script:PolicyParamsPanel.Children.Add($lbl) | Out-Null + + $tb = [System.Windows.Controls.TextBox]::new() + $tb.Width = 500 + $tb.HorizontalAlignment = 'Left' + $tb.FontSize = 12 + $tb.Padding = [System.Windows.Thickness]::new(6, 4, 6, 4) + $tb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 10) + $script:PolicyParamsPanel.Children.Add($tb) | Out-Null + $script:policyParamTextBoxes[$p.Name] = @{ TextBox = $tb; Param = $p } + } + } + + # Load scopes lazily (once per scan) + if (-not $script:policyDeployScopesLoaded -and $script:scanData.Auth) { + $script:PolicyDeployStatus.Text = 'Loading scopes...' + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [action]{}, [System.Windows.Threading.DispatcherPriority]::Background + ) + $script:policyDeployScopes = Get-PolicyScopes -Subscriptions $script:scanData.Auth.Subscriptions + $script:policyDeployScopesLoaded = $true + $script:PolicyDeployStatus.Text = '' + } + + $script:PolicyScopeSelector.Items.Clear() + foreach ($s in $script:policyDeployScopes) { + $script:PolicyScopeSelector.Items.Add($s.DisplayName) | Out-Null + } + if ($script:policyDeployScopes.Count -gt 0) { + $script:PolicyScopeSelector.SelectedIndex = 0 + } +} + +function Show-PolicyUnassignPanel { + param( + [string]$PolicyDisplayName, + [string]$PolicyDefId + ) + + $script:policyDeployCurrentDefId = $PolicyDefId + $script:policyDeployCurrentName = $PolicyDisplayName + $script:policyUnassignMode = $true + $script:PolicyDeployTitle.Text = "Unassign policy: $PolicyDisplayName" + $script:PolicyDeployStatus.Text = '' + $script:PolicyDeployPanel.Visibility = 'Visible' + $script:PolicyRemediateButton.Visibility = 'Collapsed' + + # Hide scope/effect/params (not needed for unassign) + $script:PolicyScopeSelector.Visibility = 'Collapsed' + $script:PolicyEffectSelector.Visibility = 'Collapsed' + $script:PolicyParamsPanel.Visibility = 'Collapsed' + # Hide their labels by finding previous siblings + foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) { + $parent = $ctrl.Parent + if ($parent) { + $idx = $parent.Children.IndexOf($ctrl) + if ($idx -gt 0) { $parent.Children[$idx - 1].Visibility = 'Collapsed' } + } + } + + $script:PolicyDeployButton.Content = 'Unassign Policy' + $script:PolicyDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438') + + # Find matching assignment(s) from inventory + $matchingAssignments = @() + if ($script:scanData.PolicyInv -and $script:scanData.PolicyInv.Assignments) { + $matchingAssignments = @($script:scanData.PolicyInv.Assignments | Where-Object { + $_.PolicyDefId -and $_.PolicyDefId.ToLower() -eq $PolicyDefId.ToLower() + }) + } + + if ($matchingAssignments.Count -eq 0) { + $script:PolicyDeployStatus.Text = "No assignment found for this policy in the inventory. It may be assigned with a different name." + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01') + return + } + + # Store for the unassign handler + $script:policyUnassignTargets = $matchingAssignments + $count = $matchingAssignments.Count + $script:PolicyDeployStatus.Text = "$count assignment(s) found. Click Unassign to remove." + $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray +} + +$script:policyUnassignMode = $false +$script:policyUnassignTargets = @() + +#----------------------------------------------------------------------- +# BILLING TAB POPULATION +#----------------------------------------------------------------------- +function Populate-BillingTab { + $d = $script:scanData.Billing + + if (-not $d -or -not $d.HasBillingAccess) { + $script:BillingAccessNote.Text = "[!] No billing account access. Assign Billing Reader on your billing account to see billing profiles, invoice sections, and cost allocation rules." + return + } + $script:BillingAccessNote.Text = '' + + # Billing Accounts + if ($d.BillingAccounts.Count -gt 0) { + $baRows = $d.BillingAccounts | ForEach-Object { + [PSCustomObject]@{ + 'Account Name' = $_.DisplayName + 'Agreement Type' = $_.AgreementType + 'Account Type' = $_.AccountType + 'Status' = $_.AccountStatus + } + } + $script:BillingAccountsGrid.ItemsSource = @($baRows) + } else { + $script:BillingAccountsGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No billing accounts found.' }) + } + + # Billing Profiles + if ($d.BillingProfiles.Count -gt 0) { + $bpRows = $d.BillingProfiles | ForEach-Object { + [PSCustomObject]@{ + 'Profile Name' = $_.DisplayName + 'Billing Account' = $_.BillingAccount + 'Currency' = $_.Currency + 'Invoice Day' = $_.InvoiceDay + 'Status' = $_.Status + } + } + $script:BillingProfilesGrid.ItemsSource = @($bpRows) + } else { + $script:BillingProfilesGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No billing profiles found (MCA/MPA only).' }) + } + + # Invoice Sections + if ($d.InvoiceSections.Count -gt 0) { + $isRows = $d.InvoiceSections | ForEach-Object { + [PSCustomObject]@{ + 'Section Name' = $_.DisplayName + 'Billing Profile' = $_.BillingProfile + 'Billing Account' = $_.BillingAccount + 'State' = $_.State + } + } + $script:InvoiceSectionsGrid.ItemsSource = @($isRows) + } else { + $script:InvoiceSectionsGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No invoice sections found (MCA only).' }) + } + + # EA Departments + if ($d.EADepartments.Count -gt 0) { + $script:EADeptHeader.Visibility = 'Visible' + $script:EADeptGrid.Visibility = 'Visible' + $eaRows = $d.EADepartments | ForEach-Object { + [PSCustomObject]@{ + 'Department' = $_.DisplayName + 'Billing Account' = $_.BillingAccount + 'Cost Center' = $_.CostCenter + 'Status' = $_.Status + } + } + $script:EADeptGrid.ItemsSource = @($eaRows) + } + + # Cost Allocation Rules + if ($d.CostAllocationRules.Count -gt 0) { + $carRows = $d.CostAllocationRules | ForEach-Object { + [PSCustomObject]@{ + 'Rule Name' = $_.RuleName + 'Description' = $_.Description + 'Status' = $_.Status + 'Source Count' = $_.SourceCount + 'Target Count' = $_.TargetCount + 'Created' = $_.CreatedDate + 'Updated' = $_.UpdatedDate + } + } + $script:CostAllocationGrid.ItemsSource = @($carRows) + } else { + $script:CostAllocationGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No cost allocation rules configured. Cost allocation rules let you redistribute shared costs across subscriptions.' }) + } +} + +#----------------------------------------------------------------------- +# BUDGET STATUS POPULATION +#----------------------------------------------------------------------- +function Populate-BudgetSection { + # BudgetSummaryText / BudgetGrid were removed from the XAML (budget + # management lives on the Budgets tab now). Skip silently when the + # legacy overview elements no longer exist. + if (-not $script:BudgetSummaryText -and -not $script:BudgetGrid) { return } + + $d = $script:scanData + if (-not $d.Budgets) { + if ($script:BudgetSummaryText) { $script:BudgetSummaryText.Text = 'Budget data not available.' } + return + } + + $b = $d.Budgets + $riskText = "$($b.SubsWithBudget) of $($b.SubsWithBudget + $b.SubsWithoutBudget) subscriptions have budgets ($($b.BudgetCoverage)% coverage)" + if ($b.SubsWithoutBudget -gt 0) { + $riskText += " | $($b.SubsWithoutBudget) subs have NO budget configured" + } + if ($script:BudgetSummaryText) { $script:BudgetSummaryText.Text = $riskText } + + if ($script:BudgetGrid) { + if ($b.Budgets.Count -gt 0) { + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($budget in $b.Budgets) { + $sym = Get-CurrencySymbol $budget.Currency + [void]$rows.Add([PSCustomObject]@{ + Subscription = $budget.Subscription + 'Budget Name' = $budget.BudgetName + Category = $budget.Category + 'Budget Amount' = "$sym$(([double]$budget.Amount).ToString('N2'))" + 'Actual Spend' = "$sym$(([double]$budget.ActualSpend).ToString('N2'))" + '% Used' = "$($budget.PctUsed)%" + 'Forecast' = "$sym$(([double]$budget.Forecast).ToString('N2'))" + '% Forecast' = "$($budget.PctForecast)%" + Risk = $budget.Risk + Thresholds = $budget.Thresholds + Contacts = if ($budget.ContactEmails) { $budget.ContactEmails } else { '' } + }) + } + $script:BudgetGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'% Used' -replace '%','') } -Descending) + } else { + $script:BudgetGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No budgets configured. Set up Azure Budgets to track spend against targets.' }) + } + } +} + +#----------------------------------------------------------------------- +# COST ANOMALY DETECTION (month-over-month per subscription) +#----------------------------------------------------------------------- +function Populate-AnomalySection { + $d = $script:scanData + if (-not $d.CostTrend -or -not $d.CostTrend.HasData) { + $script:AnomalyNote.Text = 'Cost trend data not available for anomaly detection.' + return + } + + # Build per-subscription month-over-month from cost data + trend + $anomalies = [System.Collections.Generic.List[PSCustomObject]]::new() + $currency = if ($d.CostTrend.Months[0].Currency) { Get-CurrencySymbol -Code $d.CostTrend.Months[0].Currency } else { '$' } + + if ($d.Costs) { + $months = $d.CostTrend.Months + $lastMonth = if ($months.Count -ge 2) { $months[$months.Count - 2] } else { $null } + $currentMonth = $months[$months.Count - 1] + + foreach ($sub in $d.Auth.Subscriptions) { + $currentCost = if ($d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id].Forecast } else { 0 } + # Use the ratio of this sub's cost to total to estimate per-sub last month + $totalCurrent = 0 + foreach ($entry in $d.Costs.GetEnumerator()) { $totalCurrent += $entry.Value.Forecast } + $subShare = if ($totalCurrent -gt 0) { $currentCost / $totalCurrent } else { 0 } + + if ($lastMonth -and $lastMonth.Cost -gt 0) { + $estLastMonth = [math]::Round($lastMonth.Cost * $subShare, 2) + if ($estLastMonth -gt 50) { + $change = $currentCost - $estLastMonth + $changePct = [math]::Round(($change / $estLastMonth) * 100, 1) + if ([math]::Abs($changePct) -ge 25) { + $direction = if ($changePct -gt 0) { 'Up' } else { 'Down' } + [void]$anomalies.Add([PSCustomObject]@{ + Subscription = $sub.Name + 'Prior Month (est.)' = "$currency$($estLastMonth.ToString('N2'))" + 'Current Forecast' = "$currency$($currentCost.ToString('N2'))" + 'Change' = "$currency$($change.ToString('N2'))" + 'Change %' = "$changePct%" + Direction = $direction + }) + } + } + } + } + } + + if ($anomalies.Count -gt 0) { + $script:AnomalyNote.Text = "$($anomalies.Count) subscription(s) with 25%+ month-over-month cost change detected." + $script:AnomalyGrid.ItemsSource = @($anomalies | Sort-Object { [math]::Abs([double]($_.'Change %' -replace '%','')) } -Descending) + } else { + $script:AnomalyNote.Text = 'No significant cost anomalies detected (all subscriptions within 25% of prior month).' + $script:AnomalyGrid.ItemsSource = @() + } +} + +#----------------------------------------------------------------------- +# COST MANAGEMENT ALERTS (API-based triggered alerts + configured rules) +#----------------------------------------------------------------------- +function Populate-AlertsSection { + $d = $script:scanData + if (-not $d.AnomalyAlerts -or -not $d.AnomalyAlerts.HasData) { + $script:AlertsSummaryNote.Text = 'No Cost Management alerts found.' + $script:TriggeredAlertsGrid.ItemsSource = @() + $script:ConfiguredRulesGrid.ItemsSource = @() + return + } + + $aa = $d.AnomalyAlerts + $parts = @() + if ($aa.TotalAlerts -gt 0) { $parts += "$($aa.TotalAlerts) triggered alert(s)" } + if ($aa.ActiveAlertCount -gt 0) { $parts += "$($aa.ActiveAlertCount) active" } + if ($aa.AnomalyAlertCount -gt 0) { $parts += "$($aa.AnomalyAlertCount) anomaly" } + if ($aa.BudgetAlertCount -gt 0) { $parts += "$($aa.BudgetAlertCount) budget" } + if ($aa.ConfiguredRuleCount -gt 0) { $parts += "$($aa.ConfiguredRuleCount) configured rule(s)" } + $script:AlertsSummaryNote.Text = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'No alerts found.' } + + # Triggered alerts grid + if ($aa.TriggeredAlerts.Count -gt 0) { + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($a in $aa.TriggeredAlerts) { + $sym = Get-CurrencySymbol $a.Unit + [void]$rows.Add([PSCustomObject]@{ + Subscription = $a.Subscription + Type = $a.AlertType + Category = $a.Category + Status = $a.Status + Amount = "$sym$(([double]$a.Amount).ToString('N2'))" + 'Current Spend' = "$sym$(([double]$a.CurrentSpend).ToString('N2'))" + Contacts = $a.Contacts + Created = $a.CreatedAt + }) + } + $script:TriggeredAlertsGrid.ItemsSource = @($rows | Sort-Object Created -Descending) + } else { + $script:TriggeredAlertsGrid.ItemsSource = @() + } + + # Configured anomaly rules grid + if ($aa.ConfiguredRules.Count -gt 0) { + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($r in $aa.ConfiguredRules) { + [void]$rows.Add([PSCustomObject]@{ + Subscription = $r.Subscription + 'Rule Name' = $r.DisplayName + Status = $r.Status + Recipients = $r.ToEmails + 'Next Run' = $r.NextRunTime + }) + } + $script:ConfiguredRulesGrid.ItemsSource = @($rows) + } else { + $script:ConfiguredRulesGrid.ItemsSource = @() + } +} + +#----------------------------------------------------------------------- +# COMMITMENT UTILIZATION POPULATION +#----------------------------------------------------------------------- +function Populate-CommitmentSection { + $d = $script:scanData + + # RI Util card + if ($d.Commitments) { + $riAvg = $d.Commitments.RIAvgUtilization + $script:RIUtilText.Text = if ($riAvg -ge 0) { "$riAvg%" } else { 'N/A' } + $riCount = $d.Commitments.Reservations.Count + $spCount = $d.Commitments.SavingsPlans.Count + $underutil = $d.Commitments.UnderutilizedRIs + $detailParts = @() + if ($riCount -gt 0) { $detailParts += "$riCount RIs" } + if ($spCount -gt 0) { $detailParts += "$spCount SPs" } + if ($underutil -gt 0) { $detailParts += "$underutil underutilized" } + $script:RIUtilDetail.Text = if ($detailParts.Count -gt 0) { $detailParts -join ' | ' } else { 'No existing commitments found' } + + # Commitment grid - combine RIs and SPs + $commitRows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($ri in $d.Commitments.Reservations) { + [void]$commitRows.Add([PSCustomObject]@{ + Type = 'Reservation' + Name = $ri.Name + 'Resource Type' = $ri.ResourceType + Quantity = $ri.Quantity + 'Utilization %' = "$($ri.UtilizationPercent)%" + Status = $ri.Status + }) + } + foreach ($sp in $d.Commitments.SavingsPlans) { + [void]$commitRows.Add([PSCustomObject]@{ + Type = 'Savings Plan' + Name = $sp.Name + 'Resource Type' = $sp.BenefitType + Quantity = '-' + 'Utilization %' = "$($sp.UtilizationPercent)%" + Status = $sp.Status + }) + } + if ($commitRows.Count -gt 0) { + $script:CommitmentGrid.ItemsSource = @($commitRows) + } else { + $script:CommitmentGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No active reservations or savings plans found.' }) + } + } else { + $script:RIUtilText.Text = 'N/A' + $script:RIUtilDetail.Text = 'Could not query commitment data' + $script:CommitmentGrid.ItemsSource = @([PSCustomObject]@{ Status = 'Commitment utilization data not available.' }) + } +} + +#----------------------------------------------------------------------- +# ORPHANED RESOURCES POPULATION +#----------------------------------------------------------------------- +function Populate-OrphanedSection { + $d = $script:scanData + + # Map orphan categories to ARM resource types for cost lookup + $categoryToType = @{ + 'Orphaned Disk' = 'microsoft.compute/disks' + 'Unattached Public IP' = 'microsoft.network/publicipaddresses' + 'Unattached NIC' = 'microsoft.network/networkinterfaces' + 'Deallocated VM' = 'microsoft.compute/virtualmachines' + 'Empty App Service Plan'= 'microsoft.web/serverfarms' + 'Old Snapshot' = 'microsoft.compute/snapshots' + } + + # Currency helper + $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { + Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency + } else { '$' } + + if ($d.Orphans -and $d.Orphans.Orphans.Count -gt 0) { + $orphans = $d.Orphans.Orphans + $script:OrphanCountText.Text = "$($orphans.Count) found" + + # Summarize by category + $byCat = $orphans | Group-Object Category + $catParts = $byCat | ForEach-Object { "$($_.Count) $($_.Name)" } + $script:OrphanDetailText.Text = ($catParts -join ', ') + + $orphanRows = [System.Collections.Generic.List[PSCustomObject]]::new() + $totalWaste = 0.0 + $costedCount = 0 + + foreach ($o in $orphans) { + $rc = $null + $armType = $categoryToType[$o.Category] + if ($armType -and $d.ResourceCosts) { + $rc = Find-ResourceCost -Name $o.ResourceName -SubscriptionId $o.SubscriptionId -ResourceGroup $o.ResourceGroup -ResourceType $armType + } + $mtdCost = if ($rc -and $rc.Actual) { $rc.Actual } else { $null } + $annualEst = if ($mtdCost -and $mtdCost -gt 0) { + $dayOfMonth = (Get-Date).Day + $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month) + $projectedMonthly = $mtdCost / $dayOfMonth * $daysInMonth + [math]::Round($projectedMonthly * 12, 2) + } else { $null } + + if ($mtdCost -and $mtdCost -gt 0) { + $totalWaste += $mtdCost + $costedCount++ + } + + [void]$orphanRows.Add([PSCustomObject]@{ + Category = $o.Category + Resource = $o.ResourceName + 'Resource Group' = $o.ResourceGroup + Location = $o.Location + Detail = $o.Detail + 'Cost (MTD)' = if ($mtdCost) { "$currency$($mtdCost.ToString('N2'))" } else { '-' } + 'Est. Annual' = if ($annualEst) { "$currency$($annualEst.ToString('N2'))" } else { '-' } + }) + } + $script:OrphanGrid.ItemsSource = @($orphanRows) + + # Summary with dollar amounts + $summary = "$($orphans.Count) orphaned/idle resources found across $($byCat.Count) categories." + if ($costedCount -gt 0) { + $annualTotal = 0.0 + $dayOfMonth = (Get-Date).Day + $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month) + $annualTotal = [math]::Round(($totalWaste / $dayOfMonth * $daysInMonth) * 12, 2) + $summary += " Estimated waste: $currency$($totalWaste.ToString('N2')) MTD ($currency$($annualTotal.ToString('N2'))/yr projected) across $costedCount costed resources." + } + $uncosted = $orphans.Count - $costedCount + if ($uncosted -gt 0) { + $summary += " $uncosted resources had no cost data (may be zero-cost or recently created)." + } + $script:OrphanSummaryText.Text = $summary + } else { + $script:OrphanCountText.Text = '0' + $script:OrphanDetailText.Text = 'No orphaned resources' + $script:OrphanSummaryText.Text = 'No orphaned or idle resources detected. Environment looks clean.' + $script:OrphanGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No orphaned resources found. All disks, IPs, NICs, VMs, and App Service Plans appear to be in use.' }) + } +} + +#----------------------------------------------------------------------- +# IDLE VM SECTION (Optimization tab) +#----------------------------------------------------------------------- +function Populate-IdleVMSection { + $d = $script:scanData + if (-not $d.IdleVMs -or -not $d.IdleVMs.HasData) { + $script:IdleVMSummaryText.Text = "No idle or underutilized VMs detected (scanned $($d.IdleVMs.ScannedVMs) running VMs)." + $script:IdleVMGrid.ItemsSource = @([PSCustomObject]@{ Status = 'All running VMs show healthy utilization. No action needed.' }) + return + } + + if (-not $script:resCostMapBuilt) { Build-ResourceCostMap } + $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { + Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency + } else { '$' } + + $idleCount = ($d.IdleVMs.IdleVMs | Where-Object { $_.Classification -eq 'Idle' }).Count + $underCount = ($d.IdleVMs.IdleVMs | Where-Object { $_.Classification -eq 'Underutilized' }).Count + $script:IdleVMSummaryText.Text = "$($d.IdleVMs.Count) VM(s) flagged: $idleCount idle, $underCount underutilized (of $($d.IdleVMs.ScannedVMs) running VMs scanned)" + + $rows = @() + foreach ($vm in $d.IdleVMs.IdleVMs) { + $rc = Find-ResourceCost -Name $vm.VMName -SubscriptionId $vm.SubscriptionId -ResourceGroup $vm.ResourceGroup -ResourceType 'microsoft.compute/virtualmachines' + $actual = if ($rc) { "$currency$($rc.Actual.ToString('N2'))" } else { '-' } + $forecast = if ($rc) { "$currency$($rc.Forecast.ToString('N2'))" } else { '-' } + $rows += [PSCustomObject]@{ + Classification = $vm.Classification + VM = $vm.VMName + 'Resource Group' = $vm.ResourceGroup + Size = $vm.VMSize + OS = $vm.OS + 'Avg CPU (14d)' = "$($vm.AvgCPU14d)%" + 'Net/Day' = $vm.NetworkPerDay + 'Cost (MTD)' = $actual + Forecast = $forecast + Recommendation = $vm.Recommendation + } + } + $script:IdleVMGrid.ItemsSource = @($rows) +} + +#----------------------------------------------------------------------- +# STORAGE TIER SECTION (Optimization tab) +#----------------------------------------------------------------------- +function Populate-StorageTierSection { + $d = $script:scanData + if (-not $d.StorageTier -or -not $d.StorageTier.HasData) { + $total = if ($d.StorageTier) { $d.StorageTier.TotalHotAccounts } else { 0 } + $script:StorageTierSummaryText.Text = "No storage tier optimization found ($total hot-tier accounts scanned)." + $script:StorageTierGrid.ItemsSource = @([PSCustomObject]@{ Status = 'All hot-tier storage accounts show healthy transaction activity. No action needed.' }) + return + } + + $archiveCount = ($d.StorageTier.Recommendations | Where-Object { $_.Recommendation -eq 'Archive' }).Count + $coolCount = ($d.StorageTier.Recommendations | Where-Object { $_.Recommendation -eq 'Cool' }).Count + $script:StorageTierSummaryText.Text = "$($d.StorageTier.Count) account(s) flagged: $archiveCount for Archive, $coolCount for Cool (of $($d.StorageTier.TotalHotAccounts) hot-tier accounts)" + + $rows = @() + foreach ($sa in $d.StorageTier.Recommendations) { + $rows += [PSCustomObject]@{ + 'Storage Account' = $sa.StorageAccount + 'Resource Group' = $sa.ResourceGroup + Location = $sa.Location + SKU = $sa.SKU + 'Current Tier' = $sa.CurrentTier + 'Capacity (GB)' = $sa.CapacityGB + 'Transactions (30d)' = $sa.Transactions30d + Recommendation = $sa.Recommendation + 'Est. Savings' = "$($sa.EstSavingsPct)%" + } + } + $script:StorageTierGrid.ItemsSource = @($rows) +} + +#----------------------------------------------------------------------- +# RESOURCES TAB (static links — no scan data needed) +#----------------------------------------------------------------------- +function Populate-ResourcesTab { + # Helper to create a clickable hyperlink block + function New-LinkBlock { + param([string]$Text, [string]$Url, [string]$Description) + $panel = [System.Windows.Controls.StackPanel]::new() + $panel.Margin = [System.Windows.Thickness]::new(0, 2, 0, 6) + + $link = [System.Windows.Documents.Hyperlink]::new() + $link.Inlines.Add($Text) + $link.NavigateUri = [Uri]::new($Url) + $link.Add_RequestNavigate({ Start-Process $_.Uri.AbsoluteUri }) + + $tb = [System.Windows.Controls.TextBlock]::new() + $tb.FontSize = 13 + $tb.Inlines.Add($link) + $panel.Children.Add($tb) | Out-Null + + if ($Description) { + $desc = [System.Windows.Controls.TextBlock]::new() + $desc.Text = $Description + $desc.FontSize = 11 + $desc.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#666') + $desc.TextWrapping = [System.Windows.TextWrapping]::Wrap + $desc.Margin = [System.Windows.Thickness]::new(12, 0, 0, 0) + $panel.Children.Add($desc) | Out-Null + } + $panel + } + + # FinOps Framework + $script:ResourcesFinOpsPanel.Children.Clear() + $finopsLinks = @( + ,@('FinOps Foundation', 'https://www.finops.org/', 'The FinOps Foundation — framework, community, certifications.') + ,@('FinOps with Azure', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/finops/', 'Microsoft Learn — FinOps principles applied to Azure.') + ,@('Cloud Adoption Framework — Cost Management', 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/manage/azure-server-management/cost-management', 'CAF discipline for managing cloud costs at enterprise scale.') + ,@('FinOps Toolkit (GitHub)', 'https://github.com/microsoft/finops-toolkit', 'Open-source Power BI reports, workbooks, and Bicep modules from Microsoft.') + ) + foreach ($item in $finopsLinks) { + $script:ResourcesFinOpsPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null + } + + # Cost Management + $script:ResourcesCostPanel.Children.Clear() + $costLinks = @( + ,@('Azure Cost Management Overview', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/overview-cost-management', 'Core service for analyzing, monitoring, and optimizing Azure costs.') + ,@('Azure Advisor — Cost Recommendations', 'https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations', 'Automated right-sizing, shutdown, and purchase recommendations.') + ,@('Azure Pricing Calculator', 'https://azure.microsoft.com/en-us/pricing/calculator/', 'Estimate costs before deploying resources.') + ,@('Cost Management Best Practices', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/cost-mgt-best-practices', 'Official best practices for Azure cost management.') + ) + foreach ($item in $costLinks) { + $script:ResourcesCostPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null + } + + # Rate Optimization + $script:ResourcesRatePanel.Children.Clear() + $rateLinks = @( + ,@('Azure Reservations', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/save-compute-costs-reservations', 'Lock in discounted rates for VMs, SQL, Cosmos, and more (30-72% savings).') + ,@('Azure Savings Plans', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/savings-plan/', 'Flexible hourly commitment across compute services (15-65% savings).') + ,@('Azure Hybrid Benefit', 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/hybrid-use-benefit-licensing', 'Use existing Windows/SQL licenses to save 40-85% on Azure VMs and SQL.') + ,@('Dev/Test Pricing', 'https://azure.microsoft.com/en-us/pricing/dev-test/', 'Discounted rates for dev/test workloads — no Windows license charges.') + ) + foreach ($item in $rateLinks) { + $script:ResourcesRatePanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null + } + + # Governance + $script:ResourcesGovernancePanel.Children.Clear() + $govLinks = @( + ,@('Azure Policy Overview', 'https://learn.microsoft.com/en-us/azure/governance/policy/overview', 'Enforce organizational standards and assess compliance at scale.') + ,@('Tagging Strategy', 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging', 'CAF tagging best practices for cost allocation and governance.') + ,@('Management Group Hierarchy', 'https://learn.microsoft.com/en-us/azure/governance/management-groups/overview', 'Organize subscriptions and apply policies at scale.') + ,@('Azure Budgets', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/tutorial-acm-create-budgets', 'Set spending thresholds and receive alerts when costs exceed targets.') + ) + foreach ($item in $govLinks) { + $script:ResourcesGovernancePanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null + } + + # Workbooks & Tools + $script:ResourcesToolsPanel.Children.Clear() + $toolLinks = @( + ,@('Orphaned Resources Workbook', 'https://github.com/dolevshor/azure-orphan-resources', 'Community Azure Workbook showing orphaned resources across subscriptions.') + ,@('Azure Optimization Engine (AOE)', 'https://github.com/helderpinto/AzureOptimizationEngine', 'Automated optimization recommendations engine using Log Analytics.') + ,@('Cost Management Labs', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/quick-acm-cost-analysis', 'Hands-on quickstart: analyze costs in the Azure portal.') + ,@('Azure Charts', 'https://azurecharts.com/', 'Visual changelog of Azure services, regions, and updates.') + ,@('Azure FinOps Multitool (this app)', 'https://github.com/z-larsen/Azure-FinOps-Multitool', 'Source code and documentation for this scanner.') + ) + foreach ($item in $toolLinks) { + $script:ResourcesToolsPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null + } +} + +#----------------------------------------------------------------------- +# BUDGETS TAB +#----------------------------------------------------------------------- +function Populate-BudgetsTab { + $d = $script:scanData + if (-not $d.Auth -or -not $d.Auth.Subscriptions) { return } + + # Populate subscription dropdown (for viewing budgets) + $script:BudgetSubSelector.Items.Clear() + $script:BudgetSubSelector.Items.Add('All Subscriptions') | Out-Null + foreach ($sub in $d.Auth.Subscriptions) { + $script:BudgetSubSelector.Items.Add($sub.Name) | Out-Null + } + $script:BudgetSubSelector.SelectedIndex = 0 + + # Populate budget deploy scope selector with actual subscriptions + $script:BudgetDeployScopeSelector.Items.Clear() + $allItem = [System.Windows.Controls.ComboBoxItem]::new() + $allItem.Content = 'All Subscriptions' + $script:BudgetDeployScopeSelector.Items.Add($allItem) | Out-Null + foreach ($sub in $d.Auth.Subscriptions) { + $item = [System.Windows.Controls.ComboBoxItem]::new() + $item.Content = $sub.Name + $item.Tag = $sub.Id + $script:BudgetDeployScopeSelector.Items.Add($item) | Out-Null + } + $script:BudgetDeployScopeSelector.SelectedIndex = 0 + + # Populate Action Group selector + $script:BudgetActionGroupSelector.Items.Clear() + $noneItem = [System.Windows.Controls.ComboBoxItem]::new() + $noneItem.Content = '(None)' + $noneItem.Tag = '' + $script:BudgetActionGroupSelector.Items.Add($noneItem) | Out-Null + foreach ($sub in $d.Auth.Subscriptions) { + try { + $agPath = "/subscriptions/$($sub.Id)/providers/microsoft.insights/actionGroups?api-version=2023-01-01" + $agResp = Invoke-AzRestMethodWithRetry -Path $agPath -Method GET + if ($agResp.StatusCode -eq 200) { + $ags = ($agResp.Content | ConvertFrom-Json).value + foreach ($ag in $ags) { + $agItem = [System.Windows.Controls.ComboBoxItem]::new() + $agItem.Content = "$($ag.name) ($($sub.Name))" + $agItem.Tag = $ag.id + $script:BudgetActionGroupSelector.Items.Add($agItem) | Out-Null + } + } + } catch { + Write-Warning "Could not list action groups for $($sub.Name): $($_.Exception.Message)" + } + } + $script:BudgetActionGroupSelector.SelectedIndex = 0 + + # Populate tag name dropdown for tag-scoped budgets + $script:BudgetDeployTagNameSelector.Items.Clear() + $noneTagItem = [System.Windows.Controls.ComboBoxItem]::new() + $noneTagItem.Content = '(No tag filter)' + $script:BudgetDeployTagNameSelector.Items.Add($noneTagItem) | Out-Null + if ($d.Tags -and $d.Tags.TagNames) { + foreach ($tagEntry in $d.Tags.TagNames.GetEnumerator()) { + $tagItem = [System.Windows.Controls.ComboBoxItem]::new() + $tagItem.Content = "$($tagEntry.Key) ($($tagEntry.Value.ResourceCount) resources)" + $tagItem.Tag = $tagEntry.Key + $script:BudgetDeployTagNameSelector.Items.Add($tagItem) | Out-Null + } + } + $script:BudgetDeployTagNameSelector.SelectedIndex = 0 + + # Populate budget policy scope selector + $script:BudgetPolicyScopeSelector.Items.Clear() + foreach ($sub in $d.Auth.Subscriptions) { + $script:BudgetPolicyScopeSelector.Items.Add("[Sub] $($sub.Name)") | Out-Null + } + if ($d.Auth.Subscriptions.Count -gt 0) { + $script:BudgetPolicyScopeSelector.SelectedIndex = 0 + } +} + +function Update-BudgetDetailView { + $d = $script:scanData + $selectedName = $script:BudgetSubSelector.SelectedItem + if (-not $selectedName -or -not $d.Budgets) { + $script:BudgetSubSummary.Text = 'No budget data available. Run a scan first.' + return + } + + $budgets = $d.Budgets.Budgets + if ($selectedName -ne 'All Subscriptions') { + $budgets = @($budgets | Where-Object { $_.Subscription -eq $selectedName }) + } + + if ($budgets.Count -gt 0) { + $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count + $atRisk = @($budgets | Where-Object { $_.Risk -eq 'At Risk' }).Count + $script:BudgetSubSummary.Text = "$($budgets.Count) budget(s) found. $overBudget over budget, $atRisk at risk." + + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($b in $budgets) { + $sym = Get-CurrencySymbol $b.Currency + [void]$rows.Add([PSCustomObject]@{ + Subscription = $b.Subscription + 'Budget Name' = $b.BudgetName + Category = $b.Category + 'Amount' = "$sym$(([double]$b.Amount).ToString('N2'))" + 'Actual Spend' = "$sym$(([double]$b.ActualSpend).ToString('N2'))" + '% Used' = "$($b.PctUsed)%" + 'Forecast' = "$sym$(([double]$b.Forecast).ToString('N2'))" + '% Forecast' = "$($b.PctForecast)%" + 'Risk' = $b.Risk + 'Tag Filter' = if ($b.TagFilter) { $b.TagFilter } else { '' } + 'Time Grain' = $b.TimeGrain + 'Thresholds' = $b.Thresholds + 'Contacts' = if ($b.ContactEmails) { $b.ContactEmails } else { '' } + }) + } + $script:BudgetDetailGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'% Used' -replace '[^0-9.]','') } -Descending) + } else { + if ($selectedName -eq 'All Subscriptions') { + $script:BudgetSubSummary.Text = "No budgets configured on any subscription. Use the section below to deploy one." + } else { + $script:BudgetSubSummary.Text = "No budget configured on '$selectedName'. Use the section below to deploy one." + } + $script:BudgetDetailGrid.ItemsSource = @() + } +} + +function Deploy-BudgetFromTab { + $d = $script:scanData + $scope = $script:BudgetDeployScopeSelector.SelectedItem.Content + $scopeSubId = $script:BudgetDeployScopeSelector.SelectedItem.Tag + $budgetName = $script:BudgetDeployNameInput.Text.Trim() + $amountText = $script:BudgetDeployAmountInput.Text.Trim() + $timeGrain = $script:BudgetDeployGrainSelector.SelectedItem.Content + $emails = $script:BudgetDeployEmailInput.Text.Trim() + + # Get selected action group + $actionGroupId = '' + if ($script:BudgetActionGroupSelector.SelectedItem -and $script:BudgetActionGroupSelector.SelectedItem.Tag) { + $actionGroupId = $script:BudgetActionGroupSelector.SelectedItem.Tag + } + + if (-not $budgetName) { + $script:BudgetDeployStatus.Foreground = '#D83B01' + $script:BudgetDeployStatus.Text = 'Budget name is required.' + return + } + if (-not $amountText -or -not [double]::TryParse($amountText, [ref]$null)) { + $script:BudgetDeployStatus.Foreground = '#D83B01' + $script:BudgetDeployStatus.Text = 'Amount must be a valid number.' + return + } + $amount = [int][double]$amountText + + # Collect user-defined thresholds (up to 4) + $thresholds = @() + $thresholdControls = @( + @{ Value = $script:BudgetThreshold1; Type = $script:BudgetThreshold1Type }, + @{ Value = $script:BudgetThreshold2; Type = $script:BudgetThreshold2Type }, + @{ Value = $script:BudgetThreshold3; Type = $script:BudgetThreshold3Type }, + @{ Value = $script:BudgetThreshold4; Type = $script:BudgetThreshold4Type } + ) + foreach ($tc in $thresholdControls) { + $val = $tc.Value.Text.Trim() + if ($val -and [double]::TryParse($val, [ref]$null)) { + $pct = [double]$val + $thresholdType = if ($tc.Type.SelectedItem) { $tc.Type.SelectedItem.Content } else { 'Actual' } + $thresholds += @{ Threshold = $pct; ThresholdType = $thresholdType } + } + } + + if ($thresholds.Count -eq 0) { + $script:BudgetDeployStatus.Foreground = '#D83B01' + $script:BudgetDeployStatus.Text = 'At least one threshold is required.' + return + } + + $startDate = (Get-Date -Day 1).ToString('yyyy-MM-01') + $endDate = (Get-Date -Day 1).AddYears(1).ToString('yyyy-MM-01') + + $contactEmails = @() + if ($emails) { $contactEmails = @($emails -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } + $contactRoles = @('Owner', 'Contributor') + + # Build notifications from user thresholds + $notifications = @{} + for ($i = 0; $i -lt $thresholds.Count; $i++) { + $t = $thresholds[$i] + $notif = @{ + enabled = $true + operator = 'GreaterThan' + threshold = $t.Threshold + thresholdType = $t.ThresholdType + contactEmails = $contactEmails + contactRoles = $contactRoles + } + if ($actionGroupId) { + $notif['contactGroups'] = @($actionGroupId) + } + $notifications["NotificationForExceededBudget$($i + 1)"] = $notif + } + + # Get tag filter values + $tagFilterName = '' + $tagFilterValue = '' + if ($script:BudgetDeployTagNameSelector.SelectedItem -and $script:BudgetDeployTagNameSelector.SelectedItem.Tag) { + $tagFilterName = $script:BudgetDeployTagNameSelector.SelectedItem.Tag + $tagFilterValue = $script:BudgetDeployTagValueInput.Text.Trim() + if ($tagFilterName -and -not $tagFilterValue) { + $script:BudgetDeployStatus.Foreground = '#D83B01' + $script:BudgetDeployStatus.Text = 'Tag value is required when a tag name is selected.' + return + } + } + + $script:BudgetDeployButton.IsEnabled = $false + $tagNote = if ($tagFilterName -and $tagFilterValue) { " (filtered by $tagFilterName=$tagFilterValue)" } else { '' } + $script:BudgetDeployStatus.Foreground = '#0078D4' + $script:BudgetDeployStatus.Text = "Deploying budget '$budgetName'$tagNote..." + + # Force UI update + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [action]{}, [System.Windows.Threading.DispatcherPriority]::Background + ) + + $successCount = 0 + $failCount = 0 + $targetSubs = @() + + if ($scope -eq 'All Subscriptions') { + $targetSubs = $d.Auth.Subscriptions + } else { + # Specific subscription selected + $targetSubs = @($d.Auth.Subscriptions | Where-Object { $_.Id -eq $scopeSubId }) + if ($targetSubs.Count -eq 0) { + $targetSubs = @($d.Auth.Subscriptions | Where-Object { $_.Name -eq $scope }) + } + } + + foreach ($sub in $targetSubs) { + try { + $budgetProps = @{ + category = 'Cost' + amount = $amount + timeGrain = $timeGrain + timePeriod = @{ startDate = $startDate; endDate = $endDate } + notifications = $notifications + } + + # Add tag filter if specified + if ($tagFilterName -and $tagFilterValue) { + $budgetProps.filter = @{ + tags = @{ + $tagFilterName = @{ + name = $tagFilterName + operator = 'In' + values = @($tagFilterValue) + } + } + } + } + + $budgetBody = @{ properties = $budgetProps } | ConvertTo-Json -Depth 10 + + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets/$($budgetName)?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method PUT -Payload $budgetBody + + if ($resp.StatusCode -in @(200, 201)) { + $successCount++ + } else { + $failCount++ + Write-Warning "Budget deploy failed on $($sub.Name): $($resp.StatusCode) $($resp.Content)" + } + } catch { + $failCount++ + Write-Warning "Budget deploy error on $($sub.Name): $($_.Exception.Message)" + } + } + + $script:BudgetDeployButton.IsEnabled = $true + if ($failCount -eq 0) { + $script:BudgetDeployStatus.Foreground = '#107C10' + $script:BudgetDeployStatus.Text = "Successfully deployed budget '$budgetName' to $successCount subscription(s) with $($thresholds.Count) threshold(s).$tagNote" + } else { + $script:BudgetDeployStatus.Foreground = '#D83B01' + $script:BudgetDeployStatus.Text = "Deployed to $successCount sub(s), $failCount failed. Check console for details." + } +} + +function Deploy-BudgetPolicyFromTab { + $d = $script:scanData + $effect = if ($script:BudgetPolicyEffectSelector.SelectedItem) { $script:BudgetPolicyEffectSelector.SelectedItem.Content } else { 'AuditIfNotExists' } + $selectedIdx = $script:BudgetPolicyScopeSelector.SelectedIndex + + if ($selectedIdx -lt 0 -or $selectedIdx -ge $d.Auth.Subscriptions.Count) { + $script:BudgetPolicyStatus.Foreground = '#D83B01' + $script:BudgetPolicyStatus.Text = 'Please select a scope.' + return + } + + $sub = $d.Auth.Subscriptions[$selectedIdx] + $scope = "/subscriptions/$($sub.Id)" + + # Built-in policy: "Budgets should be configured on subscriptions" + $policyDefId = '/providers/Microsoft.Authorization/policyDefinitions/b60f1662-afbe-4583-8543-26c9e20fa0ca' + + $script:BudgetPolicyDeployButton.IsEnabled = $false + $script:BudgetPolicyStatus.Foreground = '#0078D4' + $script:BudgetPolicyStatus.Text = "Deploying budget policy ($effect)..." + + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [System.Windows.Threading.DispatcherPriority]::Render, [action]{}) + + try { + $result = Deploy-PolicyAssignment -Scope $scope -PolicyDefinitionId $policyDefId ` + -Effect $effect -DisplayName "Budget Policy ($effect)" + if ($result.Success) { + $script:BudgetPolicyStatus.Foreground = '#107C10' + $script:BudgetPolicyStatus.Text = "Budget policy deployed ($effect) to $($sub.Name)." + } else { + $script:BudgetPolicyStatus.Foreground = '#D83B01' + $script:BudgetPolicyStatus.Text = "Failed: $($result.Message)" + } + } catch { + $script:BudgetPolicyStatus.Foreground = '#D83B01' + $script:BudgetPolicyStatus.Text = "Error: $($_.Exception.Message)" + } + $script:BudgetPolicyDeployButton.IsEnabled = $true +} + +function Start-PolicyRemediation { + param( + [Parameter(Mandatory)][string]$Scope, + [Parameter(Mandatory)][string]$PolicyAssignmentId + ) + + Write-Host " Creating remediation task for assignment: $PolicyAssignmentId" -ForegroundColor Cyan + + $remediationName = "remediate-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + $body = @{ + properties = @{ + policyAssignmentId = $PolicyAssignmentId + } + } | ConvertTo-Json -Depth 5 + + $remediationPath = "$Scope/providers/Microsoft.PolicyInsights/remediations/$($remediationName)?api-version=2021-10-01" + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $remediationPath -Method PUT -Payload $body + if ($resp.StatusCode -in @(200, 201)) { + Write-Host " Remediation task '$remediationName' created." -ForegroundColor Green + return [PSCustomObject]@{ Success = $true; Message = "Remediation task '$remediationName' created. Check Policy > Remediation in the portal for progress."; Name = $remediationName } + } else { + $errBody = ($resp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue) + $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($resp.StatusCode)" } + return [PSCustomObject]@{ Success = $false; Message = $errMsg } + } + } catch { + return [PSCustomObject]@{ Success = $false; Message = $_.Exception.Message } + } +} + +#----------------------------------------------------------------------- +# SUBSCRIPTION SCORECARD +#----------------------------------------------------------------------- +function Populate-Scorecard { + $d = $script:scanData + if (-not $d.Auth -or -not $d.Auth.Subscriptions) { return } + + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($sub in $d.Auth.Subscriptions) { + # Cost info + $costActual = 0; $costForecast = 0; $currency = 'USD' + if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { + $c = $d.Costs[$sub.Id] + $costActual = $c.Actual + $costForecast = $c.Forecast + $currency = $c.Currency + } + $sym = Get-CurrencySymbol $currency + + # Tag compliance + $tagScore = 'N/A' + if ($d.Tags -and $d.Tags.PerSubscription -and $d.Tags.PerSubscription.ContainsKey($sub.Id)) { + $tagScore = "$($d.Tags.PerSubscription[$sub.Id].Coverage)%" + } elseif ($d.Tags) { + $tagScore = "$($d.Tags.TagCoverage)%" + } + + # Optimization count + $optCount = 0 + if ($d.Optimization -and $d.Optimization.Recommendations) { + $optCount += @($d.Optimization.Recommendations | Where-Object { $_.SubscriptionId -eq $sub.Id }).Count + } + + # Orphan count + $orphanCount = 0 + $orphanSavings = 0.0 + if ($d.Orphans -and $d.Orphans.Orphans) { + $subOrphans = @($d.Orphans.Orphans | Where-Object { $_.SubscriptionId -eq $sub.Id }) + $orphanCount = $subOrphans.Count + # Estimate monthly savings per orphan category (conservative Azure pricing) + foreach ($o in $subOrphans) { + $orphanSavings += switch ($o.Category) { + 'Orphaned Disk' { + # Estimate based on disk size from Detail field + $diskGb = 0 + if ($o.Detail -match '(\d+)\s*GB') { $diskGb = [int]$Matches[1] } + if ($o.Detail -match 'Premium') { $diskGb * 0.12 } # ~$0.12/GB/mo Premium SSD + elseif ($o.Detail -match 'Standard_SSD') { $diskGb * 0.075 } + else { $diskGb * 0.04 } # Standard HDD + } + 'Unattached Public IP' { 3.65 } # ~$0.005/hr static IP + 'Unattached NIC' { 0 } # NICs are free but clutter + 'Deallocated VM' { 15 } # OS disk + IP costs while deallocated + 'Empty App Service Plan' { 55 } # Basic tier ~$55/mo + 'Old Snapshot' { 5 } # ~$0.05/GB, typical 100GB + default { 5 } + } + } + } + + # Budget risk + $budgetRisk = 'No Budget' + if ($d.Budgets -and $d.Budgets.Budgets) { + $subBudgets = @($d.Budgets.Budgets | Where-Object { $_.SubscriptionId -eq $sub.Id }) + if ($subBudgets.Count -gt 0) { + $worstRisk = ($subBudgets | Sort-Object PercentUsed -Descending | Select-Object -First 1).Risk + $budgetRisk = $worstRisk + } + } + + # Cost trend direction + $trendDir = '-' + if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months.Count -ge 2) { + $last = $d.CostTrend.Months[$d.CostTrend.Months.Count - 1].Cost + $prev = $d.CostTrend.Months[$d.CostTrend.Months.Count - 2].Cost + if ($prev -gt 0) { + $pct = [math]::Round((($last - $prev) / $prev) * 100, 1) + $trendDir = if ($pct -gt 5) { "Up $pct%" } elseif ($pct -lt -5) { "Down $([math]::Abs($pct))%" } else { 'Stable' } + } + } + + [void]$rows.Add([PSCustomObject]@{ + Subscription = $sub.Name + 'Actual (MTD)' = "$sym$($costActual.ToString('N2'))" + 'Forecast' = "$sym$($costForecast.ToString('N2'))" + 'Tag Coverage' = $tagScore + 'Optimizations' = $optCount + 'Orphaned' = $orphanCount + 'Orphan Savings' = if ($orphanSavings -gt 0) { "$sym$([math]::Round($orphanSavings, 2).ToString('N2'))/mo" } else { '-' } + 'Budget Status' = $budgetRisk + 'Cost Trend' = $trendDir + }) + } + + $script:ScorecardGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'Actual (MTD)' -replace '[^0-9.]','') } -Descending) +} + +# -- Subscription Selector Dialog ---------------------------------------- +# Shows a popup with checkboxes for each subscription. Returns only selected subs. +# Called after tenant connection so users can narrow the scan scope. +function Show-SubscriptionSelector { + param( + [Parameter(Mandatory)][object[]]$Subscriptions, + [object[]]$SkippedSubs, + [System.Windows.Window]$ParentWindow + ) + + $subCount = $Subscriptions.Count + # For small tenants (≤5 subs), skip the selector — just scan everything + if ($subCount -le 5) { return $Subscriptions } + + $dlgHeight = [math]::Min(560, 220 + ($subCount * 26)) + + $dlgXaml = @" + + + + + + + + + + + + + + + + + + + + + + + +
FinOps toolkit requires PowerShell 7, which is built into Azure Cloud Shell and supported on all major operating systems.
+ + +
+ +
+
+
Install-Module -Name Az.Accounts
+Install-Module -Name Az.ResourceGraph
+Install-Module -Name FinOpsToolkit
+Connect-AzAccount
+
+
+ +
+
+
+ +
You're now ready to scan. Run the command, then choose the subscriptions and modules to scan.
+
+
+
Start-FinOpsMultitool
+
+
+ +
+
+ + + +About the commands +💜 Give feedback + +
From a13b7a04b06081cb24cc3018d42cb9bc9a7321f0 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Thu, 2 Jul 2026 15:19:08 -0600 Subject: [PATCH 65/79] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 24 +++++++++++++------ .../modules/Get-AnomalyAlerts.ps1 | 18 ++++++++++++++ .../modules/helpers/Get-FOHubProvider.ps1 | 7 +++++- .../modules/helpers/Read-FinOpsHubData.ps1 | 7 +++++- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index cc6a6e258..6466104ca 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1157,7 +1157,12 @@ function Invoke-FinOpsMultitool { # CostData is a hashtable keyed by subscription ID if ($data -is [hashtable]) { $rows = $data.GetEnumerator() | ForEach-Object { - $subLabel = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) } + # 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 @@ -1169,16 +1174,18 @@ function Invoke-FinOpsMultitool { } } 'Get-ResourceCosts' { - $rows = @($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 20 | ForEach-Object { + $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 = @('ResourceGroup', 'ResourceType', 'Cost') - if (@($data).Count -gt 20) { - Write-Host " (showing top 20 of $(@($data).Count) resources by cost)" -ForegroundColor DarkGray + $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' { @@ -1345,7 +1352,9 @@ function Invoke-FinOpsMultitool { '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 { - [PSCustomObject]@{ Alert = $_.AlertName; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription } + $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') } @@ -2244,7 +2253,8 @@ tr:hover { background: #161b22; } 'Get-AnomalyAlerts' { [void]$htmlSb.Append("

Total: $($data.TotalAlerts)  |  Anomaly: $($data.AnomalyAlertCount)  |  Active: $($data.ActiveAlertCount)

") $htmlRows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object { - [PSCustomObject]@{ Alert = $_.AlertName; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription } + $label = if ($_.AlertLabel) { $_.AlertLabel } else { $_.AlertName } + [PSCustomObject]@{ Alert = $label; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription } } $htmlCols = @('Alert', 'Type', 'Status', 'Subscription') } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 index ebe5ad79b..3c2504fbf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -51,6 +51,21 @@ function Get-AnomalyAlerts { $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) } @@ -64,6 +79,9 @@ function Get-AnomalyAlerts { Subscription = $sub.Name SubscriptionId = $sub.Id AlertName = $alert.name + AlertLabel = $alertLabel + RelatedTo = $relatedTo + Description = $description AlertType = $alertType Category = $category Criteria = $criteria diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 303b587f7..43653f28f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -181,7 +181,7 @@ $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) by _sub +| 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' } } @@ -190,10 +190,15 @@ $scope 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 diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index b8b5666c3..a4364a820 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -393,6 +393,11 @@ function ConvertTo-CostDataFromHub { $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 } @@ -403,7 +408,7 @@ function ConvertTo-CostDataFromHub { else { 'USD' } if (-not $costMap.ContainsKey($subId)) { - $costMap[$subId] = @{ Actual = 0.0; Forecast = 0.0; Currency = $currency } + $costMap[$subId] = @{ Actual = 0.0; Forecast = 0.0; Currency = $currency; Name = $subName } } $costMap[$subId].Actual += $cost } From 8c0dab87426823619dbe138e546a4cb9154a87d4 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 17 Aug 2026 16:40:19 -0600 Subject: [PATCH 66/79] Documentation for multitool --- docs-mslearn/TOC.yml | 6 ++ docs-mslearn/toolkit/changelog.md | 4 +- .../toolkit/finops-toolkit-overview.md | 2 +- .../multitool/finops-multitool-overview.md | 79 +++++++++++++++++++ .../multitool/finops-multitool-commands.md | 30 +++---- .../multitool/start-finopsmultitool.md | 14 ++-- .../toolkit/powershell/powershell-commands.md | 4 +- docs/README.md | 2 +- docs/multitool.md | 17 ++-- docs/powershell.md | 5 ++ .../Private/FinOpsMultitool/README.md | 4 +- .../agent-skills/cost-data-source/SKILL.md | 2 +- .../agent-skills/finops-multitool/SKILL.md | 10 +-- .../sustainability-carbon/SKILL.md | 25 +++--- 14 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 docs-mslearn/toolkit/multitool/finops-multitool-overview.md diff --git a/docs-mslearn/TOC.yml b/docs-mslearn/TOC.yml index 6e968adfc..05ae3658b 100644 --- a/docs-mslearn/TOC.yml +++ b/docs-mslearn/TOC.yml @@ -194,6 +194,12 @@ href: toolkit/alerts/finops-alerts-overview.md - name: Configure alerts href: toolkit/alerts/configure-finops-alerts.md + - name: FinOps Multitool + items: + - name: Overview + href: toolkit/multitool/finops-multitool-overview.md + - name: Commands + href: toolkit/powershell/multitool/finops-multitool-commands.md - name: Optimization engine items: - name: Overview diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index fac028665..9869e7503 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -32,10 +32,10 @@ The following section lists features and enhancements that are currently in deve - Added 4 agents (CFO, FinOps practitioner, database query, hubs agent), 5 commands (`/ftk-hubs-connect`, `/ftk-hubs-healthCheck`, `/ftk-mom-report`, `/ftk-ytd-report`, `/ftk-cost-optimization`), and an output style. - Linked to the existing KQL query catalog in `src/queries/` from the plugin. -### FinOps Multitool v15.0.0 +### [FinOps multitool](multitool/finops-multitool-overview.md) v15.0.0 - **Added** - - Added the FinOps Multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a cross-platform terminal UI and an MCP server for AI agents ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Added the FinOps multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a cross-platform terminal UI and an MCP server for AI agents ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). - Includes 30 read-only scan modules covering orphaned resources, idle VMs, storage tier advice, Azure Hybrid Benefit, tag and policy inventory and recommendations, cost data, cost trend, cost by tag, resource costs, reservation advice, commitment utilization, realized savings, budget status, anomaly alerts, Advisor recommendations, billing structure, and contract info. - The MCP server exposes 40 tools (36 read-only and 4 gated write/remediation) over the Model Context Protocol, with a configurable write-safety policy that defaults to read-only. - Cost scans prefer the FinOps hub's Azure Data Explorer or Microsoft Fabric Kusto database and push aggregation into the engine to scale to large environments, with a storage reader as a small-dataset fallback. diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index 475eadba8..561036dc1 100644 --- a/docs-mslearn/toolkit/finops-toolkit-overview.md +++ b/docs-mslearn/toolkit/finops-toolkit-overview.md @@ -33,7 +33,7 @@ The FinOps toolkit is an ever-evolving collection of tools and resources. The fo - [Governance workbook](./workbooks/governance.md) – Central hub for governance. - [Azure Optimization Engine](./optimization-engine/overview.md) – Extensible solution for custom optimization recommendations. - [PowerShell module](./powershell/powershell-commands.md) – Automate and manage FinOps solutions and capabilities. -- [FinOps Multitool](./powershell/multitool/finops-multitool-commands.md) – Scan an Azure environment for cost, governance, and optimization insights from a terminal UI or an MCP server for AI agents. +- [FinOps multitool](./powershell/multitool/finops-multitool-commands.md) – Scan an Azure environment for cost, governance, and optimization insights from a terminal UI or an MCP server for AI agents. - [Bicep Registry modules](./bicep-registry/modules.md) – Official repository for Bicep modules. - [Open data](open-data.md) – Data available for anyone to access, use, and share without restriction. - [Pricing units](open-data.md#pricing-units) – Microsoft pricing units, distinct units, and scaling factors. diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md new file mode 100644 index 000000000..6c41973a2 --- /dev/null +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -0,0 +1,79 @@ +--- +title: FinOps multitool overview +description: FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights from a terminal UI or an MCP server for AI agents. +author: z-larsen +ms.author: zlarsen +ms.date: 08/13/2026 +ms.topic: concept-article +ms.service: finops +ms.subservice: finops-toolkit +ms.reviewer: micflan +#customer intent: As a FinOps practitioner, I need to learn about the FinOps multitool. +--- + +# FinOps multitool + +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 or as tools an AI agent can call. + +## How it works + +FinOps multitool runs 30 scan modules against the subscriptions you select and renders the findings in one place: + +- **Interactive scanning**
Choose the subscriptions and scan modules you want, then review results in the terminal. Findings can be exported to CSV, an HTML report, and a text summary. + +- **AI agent support**
A Model Context Protocol (MCP) server exposes the same scans as tools, so agents like GitHub Copilot can answer cost questions grounded in your environment instead of general guidance. + +- **Scales with your data**
When a [FinOps hub](../hubs/finops-hubs-overview.md) is available, cost scans query the hub's Azure Data Explorer or Microsoft Fabric database and push aggregation into the engine, returning only summarized results. A storage reader covers smaller datasets, and the Cost Management API is used when no hub is present. + +- **Safe by default**
Analysis scans are read-only. Optional remediation tools preview changes by default and are disabled unless an operator explicitly enables a write mode. + +## Benefits + +FinOps multitool shortens the path from "what is this costing us?" to a specific, actionable list. Instead of checking Azure Advisor, Cost Analysis, Resource Graph, and the budgets blade separately, you run one scan and get the findings together, scoped to the subscriptions you care about. + +## Why FinOps multitool? + +[FinOps workbooks](../workbooks/finops-workbooks-overview.md) and the [Azure Optimization Engine](../optimization-engine/overview.md) surface optimization opportunities in the Azure portal. FinOps multitool brings the same class of insight to the terminal and to AI agents, so engineers can scan an environment during a working session without switching context, and agents can ground their answers in real resource state. + +## Required permissions + +Most scans need [Reader](/azure/role-based-access-control/built-in-roles#reader) or [Cost Management Reader](/azure/role-based-access-control/built-in-roles#cost-management-reader) on the target scope. Account scans (billing structure, contract info, and Microsoft Azure Consumption Commitment balance) also need [Billing Reader](/azure/role-based-access-control/built-in-roles#billing-reader), or Enterprise Administrator (reader) on an Enterprise Agreement. The carbon scan needs Reader or Carbon Optimization Reader. + +## Give feedback + +Let us know how we're doing with a quick review. We use these reviews to improve and expand FinOps tools and resources. + + +> [!div class="nextstepaction"] +> [Give feedback](https://portal.azure.com/#view/HubsExtension/InProductFeedbackBlade/extensionName/FinOpsToolkit/cesQuestion/How%20easy%20or%20hard%20is%20it%20to%20use%20FinOps%20multitool%3F/cvaQuestion/How%20valuable%20are%20FinOps%20multitool%3F/surveyId/FTK/bladeName/Multitool/featureName/Overview) + + +If you're looking for something specific, vote for an existing or create a new idea. Share ideas with others to get more votes. We focus on ideas with the most votes. + + +> [!div class="nextstepaction"] +> [Vote on or suggest ideas](https://github.com/microsoft/finops-toolkit/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22Tool%3A%20PowerShell%22%20sort%3Areactions-%2B1-desc) + + +
+ +## Related content + +Related FinOps capabilities: + +- [Reporting and analytics](../../framework/understand/reporting.md) +- [Workload optimization](../../framework/optimize/workloads.md) +- [Rate optimization](../../framework/optimize/rates.md) + +Related products: + +- [Azure Resource Graph](/azure/governance/resource-graph/) +- [Cost Management](/azure/cost-management-billing/) + +Related solutions: + +- [FinOps multitool commands](../powershell/multitool/finops-multitool-commands.md) +- [FinOps hubs](../hubs/finops-hubs-overview.md) +- [FinOps workbooks](../workbooks/finops-workbooks-overview.md) + +
diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 360c08534..8a0203747 100644 --- a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md +++ b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md @@ -1,5 +1,5 @@ --- -title: FinOps Multitool commands +title: FinOps multitool commands description: Learn about PowerShell commands in the FinOpsToolkit module that scan an Azure environment for cost optimization, governance, and FinOps insights. author: z-larsen ms.author: zlarsen @@ -8,23 +8,23 @@ ms.topic: reference ms.service: finops ms.subservice: finops-toolkit ms.reviewer: micflan -#customer intent: As a FinOps user, I want to understand what FinOps Multitool commands are available in the FinOpsToolkit module. +#customer intent: As a FinOps user, I want to understand what FinOps multitool commands are available in the FinOpsToolkit module. --- -# FinOps Multitool commands +# FinOps multitool commands -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. +The FinOps multitool PowerShell commands help you scan an Azure environment for cost optimization, governance, and FinOps insights. Findings are grounded in your live resource state and cover cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. -The Multitool delivers the same scan engine through two interfaces: +The Multitool delivers one scan engine through two interfaces: -- **Terminal UI (TUI)** – An interactive, cross-platform terminal experience launched with [Start-FinOpsMultitool](start-finopsmultitool.md). -- **MCP server** – A Model Context Protocol server (`Start-McpServer.ps1`) that exposes the scans as tools for AI agents like GitHub Copilot. +- **Terminal UI (TUI)** – An interactive, cross-platform terminal experience launched with [Start-FinOpsMultitool](Start-FinOpsMultitool.md). It surfaces 26 of the 30 scans. +- **MCP server** – A Model Context Protocol server (`Start-McpServer.ps1`) that exposes all 30 scans as tools for AI agents like GitHub Copilot.
## Commands -- [Start-FinOpsMultitool](start-finopsmultitool.md) – Launch the interactive FinOps Multitool terminal UI. +- [Start-FinOpsMultitool](Start-FinOpsMultitool.md) – Launch the interactive FinOps multitool terminal UI.
@@ -32,19 +32,21 @@ The Multitool delivers the same scan engine through two interfaces: The Multitool includes 30 scan modules across the following categories: -- **Optimization** – Orphaned resources, idle VMs, storage tier advice, and Azure Hybrid Benefit opportunities. +- **Optimization** – Orphaned resources, idle VMs, storage tier advice, Azure Hybrid Benefit opportunities, and legacy resources. - **Governance** – Tag inventory and recommendations, and policy inventory and recommendations. -- **Cost analysis** – Cost data, cost trend, cost by tag, and top resources by cost. +- **Cost analysis** – Cost data, resource costs, cost by tag, cost trend, unit economics, VM cost breakdown, shared cost allocation, billing account, and usage allocation. - **Commitments** – Reservation advice, commitment utilization, and realized savings. -- **Monitoring** – Budget status and anomaly alerts. +- **Monitoring** – Budget status, budget history, and anomaly alerts. - **Advisor** – Azure Advisor cost recommendations. -- **Account** – Billing structure, contract info, and tenant hierarchy. +- **Account** – Billing structure, contract info, and Microsoft Azure Consumption Commitment (MACC) balance. +- **AI and ML** – Azure AI workload spend. +- **Sustainability** – Carbon emissions. -Analysis scans are read-only and use Reader or Cost Management Reader access. +Analysis scans are read-only. Most need Reader or Cost Management Reader access. Account scans also need Billing Reader, or Enterprise Administrator (reader) on an Enterprise Agreement. The carbon scan needs Reader or Carbon Optimization Reader.
-## FinOps Hub data paths +## FinOps hub data paths When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 1f417428a..d55b5131a 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -1,6 +1,6 @@ --- title: Start-FinOpsMultitool command -description: Launch the FinOps Multitool interactive terminal UI to scan an Azure environment for cost optimization, governance, and FinOps insights. +description: Launch the FinOps multitool interactive terminal UI to scan an Azure environment for cost optimization, governance, and FinOps insights. author: z-larsen ms.author: zlarsen ms.date: 07/02/2026 @@ -13,11 +13,11 @@ ms.reviewer: micflan # Start-FinOpsMultitool command -The **Start-FinOpsMultitool** command launches the FinOps Multitool interactive terminal UI (TUI). The tool authenticates to Azure, discovers accessible 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. +The **Start-FinOpsMultitool** command launches the FinOps multitool interactive terminal UI (TUI). The tool authenticates to Azure, discovers accessible 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. +Results are rendered in the terminal. When you choose to export, the tool writes a CSV file per scan module, an `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. -The command runs on PowerShell 7+ (cross-platform) and requires the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules with at least Reader access on the target scope. +The command runs on PowerShell 5.1 or later on Windows, and PowerShell 7 or later on all platforms. It requires the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules. Most scans need Reader or Cost Management Reader access on the target scope. Account scans (billing structure, contract info, and MACC commitment) also need Billing Reader, or Enterprise Administrator (reader) on an Enterprise Agreement. The carbon scan needs Reader or Carbon Optimization Reader.
@@ -71,9 +71,9 @@ Launches the terminal UI and writes exported result files to the specified direc
-## FinOps Hub data paths +## FinOps hub data paths -When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, choosing the **FinOps Hub** data source prefers the hub's Azure Data Explorer or Microsoft Fabric Kusto database—aggregation is pushed into the engine and only summarized results are returned, so large hubs are never loaded into PowerShell. To query a local hub on your own hardware, set `FINOPS_HUB_KUSTO_URI` to a local Kusto endpoint. When no Kusto cluster is reachable, the Multitool falls back to reading the hub storage export, which is intended for smaller datasets. For more information, see [FinOps Multitool commands](finops-multitool-commands.md). +When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, choosing the **FinOps Hub** data source prefers the hub's Azure Data Explorer or Microsoft Fabric Kusto database—aggregation is pushed into the engine and only summarized results are returned, so large hubs are never loaded into PowerShell. To query a local hub on your own hardware, set `FINOPS_HUB_KUSTO_URI` to a local Kusto endpoint. When no Kusto cluster is reachable, the Multitool falls back to reading the hub storage export, which is intended for smaller datasets. For more information, see [FinOps multitool commands](finops-multitool-commands.md).
@@ -81,7 +81,7 @@ When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, choosing the Related solutions: -- [FinOps Multitool commands](finops-multitool-commands.md) +- [FinOps multitool commands](finops-multitool-commands.md) - [FinOps toolkit PowerShell module](../powershell-commands.md) - [FinOps hubs](../../hubs/finops-hubs-overview.md) diff --git a/docs-mslearn/toolkit/powershell/powershell-commands.md b/docs-mslearn/toolkit/powershell/powershell-commands.md index 65e74f50b..37507a0c3 100644 --- a/docs-mslearn/toolkit/powershell/powershell-commands.md +++ b/docs-mslearn/toolkit/powershell/powershell-commands.md @@ -59,9 +59,9 @@ The FinOps toolkit PowerShell module includes commands to manage FinOps solution - [Remove-FinOpsCostExport](cost/Remove-FinOpsCostExport.md) – Delete a Cost Management export and optionally data associated with the export. - [Start-FinOpsCostExport](cost/Start-FinOpsCostExport.md) – Initiates a Cost Management export run for the most recent period. -### FinOps Multitool commands +### FinOps multitool commands -- [Start-FinOpsMultitool](multitool/start-finopsmultitool.md) – Launch the interactive FinOps Multitool terminal UI to scan for cost, governance, and optimization insights. +- [Start-FinOpsMultitool](multitool/Start-FinOpsMultitool.md) – Launch the interactive FinOps multitool terminal UI to scan for cost, governance, and optimization insights. ### FinOps hubs commands diff --git a/docs/README.md b/docs/README.md index ced23f9b7..badb15852 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,7 +71,7 @@ Automate and extend the Microsoft Cloud with starter kits, scripts, and advanced Learn more
-
🛠️ FinOps Multitool
+
🛠️ FinOps multitool
Scan your environment for cost, governance, and optimization insights.
Learn more
diff --git a/docs/multitool.md b/docs/multitool.md index c5f59cce8..0f3b4b634 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -1,14 +1,14 @@ --- layout: default -title: FinOps Multitool -browser: FinOps Multitool - Scan your Azure environment for FinOps insights +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.' +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 +#customer intent: As a FinOps practitioner, I need to learn about the FinOps multitool --- -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 } @@ -17,12 +17,12 @@ Scan your Azure environment for cost optimization, governance, and FinOps insigh --- -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. +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. + 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

@@ -43,7 +43,7 @@ The FinOps Multitool scans an Azure environment for cost optimization, governanc Learn more
-
🏦 FinOps Hub data paths
+
🏦 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
@@ -77,6 +77,7 @@ The FinOps Multitool scans an Azure environment for cost optimization, governanc
Install-Module -Name Az.Accounts
 Install-Module -Name Az.ResourceGraph
+Install-Module -Name Az.Storage
 Install-Module -Name FinOpsToolkit
 Connect-AzAccount
 
diff --git a/docs/powershell.md b/docs/powershell.md index 26cd4b910..8e76aab2f 100644 --- a/docs/powershell.md +++ b/docs/powershell.md @@ -42,6 +42,11 @@ The FinOps toolkit PowerShell module helps you automate and scale common Cost Ma
Deploy and manage FinOps hubs and configured scopes.
See commands
+
+
🛠️ FinOps multitool
+
Scan your environment for cost, governance, and optimization insights.
+ See commands +
🌐 Open data
Query FinOps toolkit open data to integrate with your own data.
diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 6fdb4a8c2..bb3f4e0ad 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -1,4 +1,4 @@ -# FinOps Multitool — Terminal UI (TUI) +# 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. @@ -323,7 +323,7 @@ $costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInve ## 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. +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. diff --git a/src/templates/agent-skills/cost-data-source/SKILL.md b/src/templates/agent-skills/cost-data-source/SKILL.md index 1206ca0e0..a06e0a786 100644 --- a/src/templates/agent-skills/cost-data-source/SKILL.md +++ b/src/templates/agent-skills/cost-data-source/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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: diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 8a75e91d0..59f7d3eaa 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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: @@ -8,9 +8,9 @@ metadata: version: '1.0' --- -# FinOps Multitool +# 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 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. @@ -73,9 +73,9 @@ How to drive them safely: 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) +## 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: +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 | | ------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | diff --git a/src/templates/agent-skills/sustainability-carbon/SKILL.md b/src/templates/agent-skills/sustainability-carbon/SKILL.md index ab377cc9f..4ddfc3cb7 100644 --- a/src/templates/agent-skills/sustainability-carbon/SKILL.md +++ b/src/templates/agent-skills/sustainability-carbon/SKILL.md @@ -5,7 +5,7 @@ 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" + version: '1.0' --- # Sustainability and carbon @@ -18,11 +18,12 @@ Use it when the user mentions carbon, emissions, sustainability, ESG, green/effi ## 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 | +| Tool | Provides | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| **`scan_carbon`** (finops-multitool) | kgCO2e totals, month-over-month change, 12-month trend, and per-subscription breakdown. Start here in a multitool-driven session. | +| **Emissions Impact Dashboard (EID)** | Scope 1/2/3 emissions for the Microsoft Cloud footprint, by service/subscription/time | +| **Azure carbon optimization** | Per-resource emissions estimates and reduction recommendations in the portal | +| **Cloud for Sustainability** | Broader org-level sustainability data model | Reference: https://learn.microsoft.com/azure/carbon-optimization/ @@ -30,12 +31,12 @@ Reference: https://learn.microsoft.com/azure/carbon-optimization/ 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 | +| 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. From d551f10a4aafe5a0438075bb7677cb4c1ff11367 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 17 Aug 2026 16:49:35 -0600 Subject: [PATCH 67/79] Update FinOps multitool --- .../FinOpsMultitool/FinOpsMultitool.psm1 | 2 - .../modules/Deploy-PolicyAssignment.ps1 | 212 ---------- .../modules/Deploy-ResourceTag.ps1 | 217 ---------- .../modules/Enable-HybridBenefit.ps1 | 32 +- .../modules/Get-AIWorkloadMetrics.ps1 | 5 +- .../modules/Get-BudgetStatus.ps1 | 4 +- .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 7 +- .../FinOpsMultitool/modules/Get-CostData.ps1 | 22 +- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 52 +-- .../modules/Get-PolicyInventory.ps1 | 140 ++++--- .../modules/Get-PolicyRecommendations.ps1 | 2 +- .../modules/Get-ResourceCosts.ps1 | 387 +++++++++--------- .../modules/Get-SharedCostAllocation.ps1 | 6 +- .../modules/Get-StorageTierAdvice.ps1 | 47 ++- .../modules/Get-VmCostBreakdown.ps1 | 8 +- .../modules/Remove-OrphanedResource.ps1 | 12 +- .../modules/Set-CostAllocationRule.ps1 | 4 +- .../FinOpsMultitool/modules/Stop-IdleVm.ps1 | 4 +- .../modules/helpers/Get-PlainAccessToken.ps1 | 12 +- .../helpers/Invoke-AzRestMethodWithRetry.ps1 | 15 +- .../modules/helpers/Read-FinOpsHubData.ps1 | 119 +++++- .../modules/helpers/Search-AzGraphSafe.ps1 | 31 +- .../Unit/FinOpsMultitool.McpServer.Tests.ps1 | 27 ++ .../FinOpsMultitool.WriteSafety.Tests.ps1 | 218 ++++++++++ .../Unit/Start-FinOpsMultitool.Tests.ps1 | 28 +- 25 files changed, 817 insertions(+), 796 deletions(-) delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 create mode 100644 src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 create mode 100644 src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index da1e6395e..abd92c626 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -66,7 +66,6 @@ $modulePath = Join-Path $PSScriptRoot 'modules' . (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') @@ -79,7 +78,6 @@ $modulePath = Join-Path $PSScriptRoot 'modules' . (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') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 deleted file mode 100644 index a3481d94d..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 +++ /dev/null @@ -1,212 +0,0 @@ -########################################################################### -# 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 deleted file mode 100644 index df5d9aa6d..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 +++ /dev/null @@ -1,217 +0,0 @@ -########################################################################### -# 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 index 0f3f44287..60d61280f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 @@ -78,9 +78,9 @@ function Enable-HybridBenefit { 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 + 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 } } @@ -130,7 +130,7 @@ function Enable-HybridBenefit { 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" + Uri = "$(Get-FinOpsArmEndpoint)$path" ResourceId = $ResourceId ResourceName = $vmName Location = $location @@ -159,18 +159,18 @@ function Enable-HybridBenefit { } 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 + HasData = $true + Mode = 'Apply' + Applied = $ok + WriteMode = $decision.Mode + StatusCode = $status + Warning = if ($ok) { "AHB enabled ($LicenseType). Reversible - set licenseType back to None to undo." } else { $null } + Error = $errMsg + Method = 'PATCH' + Uri = "$(Get-FinOpsArmEndpoint)$path" + ResourceId = $ResourceId + ResourceName = $vmName CurrentLicense = $currentLicense - NewLicense = $LicenseType + NewLicense = $LicenseType } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 5b843ca3f..5a4236375 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -164,7 +164,8 @@ resources if (-not $fromHub -and $openAiAccounts.Count -gt 0) { $token = $null - try { $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token } catch { } + $armBase = Get-FinOpsArmEndpoint + try { $token = (Get-AzAccessToken -ResourceUrl $armBase).Token } catch { } if ($token) { $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } @@ -189,7 +190,7 @@ resources # 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" + $metricUri = "$armBase$($acct.Id)/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=$metricNames×pan=$fromStr/$toStr&aggregation=Total&interval=P1D$filterSeg" try { $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 20 -ErrorAction Stop diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 5b1baf423..2e49ee4bf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -43,8 +43,8 @@ function Get-BudgetStatus { $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++ } + $sampleBudgets = ($resp.Content | ConvertFrom-Json).value + if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } } } catch { } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 index 7b13837d9..68e6941d9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -20,11 +20,10 @@ function Get-CostByTag { [Parameter()] [hashtable]$ExistingTags, + # No -RestrictToSelected here: this scan queries each subscription + # individually, so it is always scoped to $Subscriptions. [Parameter()] - [object[]]$Subscriptions, - - [Parameter()] - [switch]$RestrictToSelected + [object[]]$Subscriptions ) # Tags we want to break cost down by (in priority order — matches CAF allocation tags) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index a290f339c..9fce0a5eb 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -82,11 +82,27 @@ function Get-CostData { $result = ($response.Content | ConvertFrom-Json) + # Resolve column indices by name. The MG-scope response is not contractually + # ordered, and a reorder would silently attribute cost to the wrong sub. + $aCols = $result.properties.columns + $aCostIdx = -1; $aSubIdx = -1; $aCurIdx = -1 + if ($aCols) { + for ($ci = 0; $ci -lt $aCols.Count; $ci++) { + $cn = ([string]$aCols[$ci].name).ToLower() + if ($cn -eq 'subscriptionid') { $aSubIdx = $ci } + elseif ($cn -eq 'currency') { $aCurIdx = $ci } + elseif ($cn -match 'cost|pretaxcost') { $aCostIdx = $ci } + } + } + if ($aCostIdx -eq -1) { $aCostIdx = 0 } + if ($aSubIdx -eq -1) { $aSubIdx = 1 } + if ($aCurIdx -eq -1) { $aCurIdx = 2 } + if ($result.properties.rows) { foreach ($row in $result.properties.rows) { - $subId = $row[1] - $amount = [math]::Round($row[0], 2) - $currency = $row[2] + $subId = $row[$aSubIdx] + $amount = [math]::Round($row[$aCostIdx], 2) + $currency = $row[$aCurIdx] if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index a34c6e03b..091be924c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -41,7 +41,8 @@ resources $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 { + } + catch { Write-Warning " Running VM query failed: $($_.Exception.Message)" $runningVMs = @() } @@ -54,24 +55,25 @@ resources 'No virtual machines found in scope.' } return [PSCustomObject]@{ - IdleVMs = @() - Count = 0 - HasData = $false - ScannedVMs = 0 - TotalVMs = $totalVMs - DeallocatedVMs = $deallocatedCount - Note = $note + 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 + $armBase = Get-FinOpsArmEndpoint + $token = (Get-AzAccessToken -ResourceUrl $armBase).Token $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } $now = (Get-Date).ToUniversalTime() $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ') $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') - $cpuThreshold = 5 # avg CPU < 5% = idle + $cpuThreshold = 5 # avg CPU < 5% = idle $networkThreshold = 1048576 # < 1 MB/day total network = idle (14d * 1MB = 14MB) $networkThreshold14d = $networkThreshold * 14 @@ -87,7 +89,7 @@ resources $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" + $metricUri = "$armBase$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=P14D" $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $metricData = ($resp.Content | ConvertFrom-Json) @@ -123,7 +125,8 @@ resources 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)) { + } + elseif ($avgCpu -ne $null -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) { $isIdle = $true $classification = 'Underutilized' } @@ -131,19 +134,20 @@ resources 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' } - }) + 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 { + } + catch { # Metrics not available — skip this VM } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index 05c0d05a7..29fe34da3 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -25,9 +25,9 @@ function Get-PolicyInventory { Write-Host " Scanning policy assignments across $subCount subscriptions..." -ForegroundColor Cyan $allAssignments = [System.Collections.Generic.List[PSCustomObject]]::new() - $complianceMap = @{} + $complianceMap = @{} $gotAssignments = $false - $gotCompliance = $false + $gotCompliance = $false # -- Strategy 1: ARM REST API for ALL effective assignments ---------- # Resource Graph policyresources at subscription scope only returns @@ -49,31 +49,33 @@ function Get-PolicyInventory { if ($seenIds.ContainsKey($a.id)) { continue } $seenIds[$a.id] = $true - $props = $a.properties - $defId = $props.policyDefinitionId + $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 { '' } + 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 { '' } - }) + 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 } + } + else { $null } } } @@ -81,7 +83,8 @@ function Get-PolicyInventory { $gotAssignments = $true Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green } - } catch { + } + catch { Write-Warning " ARM REST policy query failed: $($_.Exception.Message)" } @@ -105,31 +108,33 @@ policyresources $props = $r.properties $defId = $props.policyDefinitionId $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } - elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } - else { 'Custom' } + 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 { '' } - }) + 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 } + } + else { $skipToken = $null } } while ($skipToken) if ($allAssignments.Count -gt 0) { $gotAssignments = $true Write-Host " Resource Graph fallback: $($allAssignments.Count) assignments" -ForegroundColor Green } - } catch { + } + catch { Write-Warning " Resource Graph policy query failed: $($_.Exception.Message)" } } @@ -166,13 +171,16 @@ policyresources TotalResources = $row.Total NonCompliant = $row.NonCompliant Compliant = $row.Compliant - PolicyCount = 0 + # Not derivable from the compliance query; the REST fallback + # computes it. $null distinguishes "unknown" from a real zero. + PolicyCount = $null } } $gotCompliance = $true Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green } - } catch { + } + catch { Write-Warning " Resource Graph compliance query failed: $($_.Exception.Message)" } @@ -195,16 +203,17 @@ policyresources 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 + 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 { + } + catch { Write-Warning " Policy compliance failed for $($sub.Name): $($_.Exception.Message)" } } @@ -233,19 +242,20 @@ policyresources 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 { '' } - }) + 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 { + } + catch { Write-Warning " Policy assignments failed for $($sub.Name): $($_.Exception.Message)" } } @@ -263,23 +273,23 @@ policyresources } # -- Compliance totals --------------------------------------------- - $totalCompliant = 0 + $totalCompliant = 0 $totalNonCompliant = 0 foreach ($c in $complianceMap.Values) { - $totalCompliant += $c.Compliant + $totalCompliant += $c.Compliant $totalNonCompliant += $c.NonCompliant } $totalEvaluated = $totalCompliant + $totalNonCompliant - $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 } + $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 } return [PSCustomObject]@{ - Assignments = $unique - AssignmentCount = $unique.Count + Assignments = $unique + AssignmentCount = $unique.Count ComplianceBySubMap = $complianceMap - CompliancePct = $compliancePct - TotalCompliant = $totalCompliant - TotalNonCompliant = $totalNonCompliant - TotalEvaluated = $totalEvaluated - HasComplianceData = ($totalEvaluated -gt 0) + 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 index d615843d9..32f59208a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -57,7 +57,7 @@ function Get-PolicyRecommendations { ) } [PSCustomObject]@{ - PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcbef5-37f7' + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070' DisplayName = 'Inherit a tag from the resource group if missing' Category = 'Tags' Pillar = 'Understand' diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 index 6bacca3ca..7e6f9e4bf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -34,48 +34,48 @@ function Get-ResourceCosts { # 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) + $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' + '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 @@ -95,7 +95,7 @@ function Get-ResourceCosts { aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } - grouping = @( + grouping = @( @{ type = 'Dimension'; name = 'ResourceId' } @{ type = 'Dimension'; name = 'ResourceGroupName' } ) @@ -113,8 +113,8 @@ function Get-ResourceCosts { 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 + for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) { + $cols[$result.properties.columns[$colIdx].name] = $colIdx } $page = $result @@ -126,10 +126,10 @@ function Get-ResourceCosts { 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']] + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] $resourceId = $row[$cols['ResourceId']] - $rg = $row[$cols['ResourceGroupName']] + $rg = $row[$cols['ResourceGroupName']] $resType = 'Unknown' $resName = $resourceId @@ -140,14 +140,14 @@ function Get-ResourceCosts { } [void]$allRows.Add([PSCustomObject]@{ - Subscription = '' - ResourceGroup = $rg - ResourceType = $resType - ResourcePath = $resourceId - Actual = $cost - Forecast = [math]::Round($cost * $forecastMult, 2) - Currency = $currency - }) + Subscription = '' + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resourceId + Actual = $cost + Forecast = [math]::Round($cost * $forecastMult, 2) + Currency = $currency + }) } } if ($page.properties.nextLink) { @@ -155,7 +155,8 @@ function Get-ResourceCosts { $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } else { break } - } else { break } + } + else { break } } while ($true) if ($allRows.Count -gt 0) { @@ -190,176 +191,182 @@ function Get-ResourceCosts { } } } - } else { + } + else { if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)" } - } catch { + } + 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)..." - } + $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 } - $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' } - ) + $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)..." } - } | ConvertTo-Json -Depth 10 + } + $basePath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" - $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body + # -- 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 - if ($resp.StatusCode -eq 200) { - $result = ($resp.Content | ConvertFrom-Json) + $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body - # 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 - } + if ($resp.StatusCode -eq 200) { + $result = ($resp.Content | ConvertFrom-Json) - # 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']] + # Build column index from response metadata (same for all pages) + # Distinct loop variable: $i is the outer per-subscription counter. + $cols = @{} + for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) { + $cols[$result.properties.columns[$colIdx].name] = $colIdx + } - # 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\.', '' } - } + # 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 + $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) } + # 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 } - } else { break } - } while ($true) + } while ($true) + } + } + catch { + Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)" } - } 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) + # -- 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' } + 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') } - } - 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] + 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) } - $subForecast = [math]::Round($forecastTotal, 2) } } - } catch { - # Forecast not available for all account types + 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) + # 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) + # 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-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 3f71762e1..e59c0cd84 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -51,11 +51,11 @@ function Resolve-SharedCostPool { $clauses = @() if ($ResourceIds -and $ResourceIds.Count -gt 0) { - $idList = ($ResourceIds | ForEach-Object { "'$_'" }) -join ',' + $idList = ($ResourceIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' $clauses += "id in~ ($idList)" } if ($ResourceGroup) { - $clauses += "resourceGroup =~ '$ResourceGroup'" + $clauses += "resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'" } if ($clauses.Count -eq 0) { return @() } @@ -132,7 +132,7 @@ AzureNetworkAnalytics_CL } } 'resourceCount' { - $spokeList = ($Spokes | ForEach-Object { "'$_'" }) -join ',' + $spokeList = ($Spokes | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' $query = @" resources | where subscriptionId in~ ($spokeList) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index 25bf6faa9..17ff0e956 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -34,13 +34,15 @@ resources $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 { + } + 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 + $armBase = Get-FinOpsArmEndpoint + $token = (Get-AzAccessToken -ResourceUrl $armBase).Token $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } $now = (Get-Date).ToUniversalTime() $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') @@ -50,7 +52,7 @@ resources $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" + $metricUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D" $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $metricData = ($resp.Content | ConvertFrom-Json) @@ -64,7 +66,7 @@ resources } # 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" + $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D" $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue $capacityBytes = 0 if ($capResp) { @@ -85,29 +87,32 @@ resources if ($totalTx -eq 0 -and $capacityGB -gt 0) { $recommendation = 'Archive' $estSavingsPct = 90 - } elseif ($totalTx -lt 100 -and $capacityGB -gt 0) { + } + elseif ($totalTx -lt 100 -and $capacityGB -gt 0) { $recommendation = 'Archive' $estSavingsPct = 90 - } elseif ($totalTx -lt 1000 -and $capacityGB -gt 1) { + } + 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 - }) + 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 { + } + catch { # Metrics not available (classic account, no blob service, etc.) — skip } } @@ -115,9 +120,9 @@ resources Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray [PSCustomObject]@{ - Recommendations = @($results) + Recommendations = @($results) TotalHotAccounts = $hotAccounts.Count - Count = $results.Count - HasData = ($results.Count -gt 0) + Count = $results.Count + HasData = ($results.Count -gt 0) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index 75e71cd88..0ded37666 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -83,13 +83,13 @@ function Resolve-VmAssociation { ) $where = if ($ResourceId) { - "id =~ '$ResourceId'" + "id =~ '$(ConvertTo-KqlLiteral $ResourceId)'" } elseif ($ResourceGroup) { - "name =~ '$VmName' and resourceGroup =~ '$ResourceGroup'" + "name =~ '$(ConvertTo-KqlLiteral $VmName)' and resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'" } else { - "name =~ '$VmName'" + "name =~ '$(ConvertTo-KqlLiteral $VmName)'" } $vmQuery = @" @@ -127,7 +127,7 @@ resources # Resolve public IPs attached to the VM's NICs if ($nicIds.Count -gt 0) { - $nicList = ($nicIds | ForEach-Object { "'$_'" }) -join ',' + $nicList = ($nicIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' $pipQuery = @" resources | where type =~ 'microsoft.network/networkinterfaces' diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 index ea438426e..25d6356b0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 @@ -46,10 +46,10 @@ function Remove-OrphanedResource { # 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' } + '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 ---- @@ -214,7 +214,7 @@ function Remove-OrphanedResource { WriteMode = $decision.Mode Warning = "PREVIEW ONLY - nothing was deleted. $irreversible $($decision.Reason)" Method = 'DELETE' - Uri = "https://management.azure.com$path" + Uri = "$(Get-FinOpsArmEndpoint)$path" ResourceId = $ResourceId ResourceName = $resName ResourceType = $fullType @@ -260,7 +260,7 @@ function Remove-OrphanedResource { Warning = if ($ok) { 'Resource deleted. This is irreversible.' } else { $null } Error = $errMsg Method = 'DELETE' - Uri = "https://management.azure.com$path" + Uri = "$(Get-FinOpsArmEndpoint)$path" ResourceId = $ResourceId ResourceName = $resName ResourceType = $fullType diff --git a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 index a7650a31c..42f673298 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 @@ -258,7 +258,7 @@ function Set-CostAllocationRule { 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" + Uri = "$(Get-FinOpsArmEndpoint)$path" BillingAccountId = $BillingAccountId RuleName = $RuleName Status = $Status @@ -305,7 +305,7 @@ function Set-CostAllocationRule { 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" + Uri = "$(Get-FinOpsArmEndpoint)$path" BillingAccountId = $BillingAccountId RuleName = $RuleName Status = $Status diff --git a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 index 0fa45329f..d5520dd4f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 @@ -113,7 +113,7 @@ function Stop-IdleVm { 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" + Uri = "$(Get-FinOpsArmEndpoint)$deallocPath" ResourceId = $ResourceId ResourceName = $vmName CurrentPowerState = $powerState @@ -146,7 +146,7 @@ function Stop-IdleVm { 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" + Uri = "$(Get-FinOpsArmEndpoint)$deallocPath" ResourceId = $ResourceId ResourceName = $vmName Async = ($status -eq 202) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 index 6df8e529b..f4b90d576 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 @@ -1,8 +1,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# ARM endpoint for the cloud the user is actually signed in to. Hardcoding the +# public URL breaks Azure Government and Azure China. +function Get-FinOpsArmEndpoint { + $url = $null + try { $url = (Get-AzContext).Environment.ResourceManagerUrl } catch { } + if ([string]::IsNullOrWhiteSpace($url)) { $url = 'https://management.azure.com' } + return $url.TrimEnd('/') +} + function Get-PlainAccessToken { - param([string]$ResourceUrl = 'https://management.azure.com') + param([string]$ResourceUrl) + if ([string]::IsNullOrWhiteSpace($ResourceUrl)) { $ResourceUrl = Get-FinOpsArmEndpoint } $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token if ($tok -is [securestring]) { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 index 2ec3e9b71..5541da051 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 @@ -152,20 +152,29 @@ function Invoke-AzRestMethodWithRetry { $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = if ($resp.Headers) { $resp.Headers } else { @{} } } } - if ($resp.StatusCode -ne 429) { return $resp } + # 429 and transient 5xx are both retryable. 5xx also covers the synthetic + # 503 this function raises for transport failures, which are the most + # likely thing to succeed on a second attempt. + $isThrottled = ($resp.StatusCode -eq 429) + $isServerErr = ($resp.StatusCode -ge 500 -and $resp.StatusCode -le 599) + if (-not ($isThrottled -or $isServerErr)) { return $resp } + if ($attempt -eq $MaxRetries) { return $resp } # Parse Retry-After header or default to exponential backoff $retryAfter = 10 - if ($resp.Headers -and $resp.Headers['Retry-After']) { + if ($isThrottled -and $resp.Headers -and $resp.Headers['Retry-After']) { $parsed = 0 if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) { $retryAfter = [math]::Max($parsed, 5) } } + elseif ($isServerErr) { + $retryAfter = [math]::Min(2 * [math]::Pow(2, $attempt), 30) + } else { $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) } - $friendly = Get-NextThrottleMessage + $friendly = if ($isThrottled) { Get-NextThrottleMessage } else { "Azure returned $($resp.StatusCode) - retrying..." } Write-Host " $friendly" -ForegroundColor Yellow if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index a4364a820..d406f2595 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -35,6 +35,92 @@ function ConvertTo-HashtableFromJson { return $ht } +function Get-FinOpsParquetCachePath { + # Per-user cache, not the world-writable shared temp dir. On a multi-user or + # shared host, %TEMP%/tmp lets another local principal pre-plant DLLs at a + # predictable path that we would then load into this process. + $base = [Environment]::GetFolderPath('LocalApplicationData') + if ([string]::IsNullOrWhiteSpace($base)) { $base = [System.IO.Path]::GetTempPath() } + return (Join-Path (Join-Path $base 'FinOpsMultitool') 'parquet') +} + +# Every managed/native DLL the loader can pull in, in a stable order. +function Get-ParquetPayloadFile { + param([Parameter(Mandatory)][string]$BasePath) + $roots = @((Join-Path $BasePath 'lib'), (Join-Path $BasePath 'runtimes')) + $files = foreach ($r in $roots) { + if (Test-Path -LiteralPath $r) { + Get-ChildItem -LiteralPath $r -Filter '*.dll' -Recurse -File -ErrorAction SilentlyContinue + } + } + return @($files | Sort-Object FullName) +} + +function New-ParquetManifest { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$ManifestPath + ) + $entries = @{} + foreach ($f in (Get-ParquetPayloadFile -BasePath $BasePath)) { + $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/') + $entries[$rel] = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash + } + [PSCustomObject]@{ + version = '4.24.0' + created = (Get-Date).ToUniversalTime().ToString('o') + files = $entries + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding UTF8 +} + +# Re-hashes every payload DLL against the manifest recorded at install time. +# Runs on EVERY load, so a tampered cache cannot ride in behind a marker file. +function Test-ParquetManifest { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$ManifestPath + ) + if (-not (Test-Path -LiteralPath $ManifestPath)) { return $false } + try { + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch { return $false } + if (-not $manifest.files) { return $false } + + $recorded = @{} + foreach ($p in $manifest.files.PSObject.Properties) { $recorded[$p.Name] = [string]$p.Value } + if ($recorded.Count -eq 0) { return $false } + + $onDisk = Get-ParquetPayloadFile -BasePath $BasePath + if ($onDisk.Count -ne $recorded.Count) { return $false } + + foreach ($f in $onDisk) { + $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/') + if (-not $recorded.ContainsKey($rel)) { return $false } + if ((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash -ne $recorded[$rel]) { return $false } + } + return $true +} + +# Validates nuget.org repository signatures on the packages we just fetched. +# TLS alone only proves who we talked to, not that the payload is authentic. +function Assert-NuGetPackageSignature { + param( + [Parameter(Mandatory)][string]$NuGetExe, + [Parameter(Mandatory)][string]$PackageDir + ) + $nupkgs = @(Get-ChildItem -LiteralPath $PackageDir -Filter '*.nupkg' -Recurse -File -ErrorAction SilentlyContinue) + if ($nupkgs.Count -eq 0) { + throw "No .nupkg files were retained for signature verification. Refusing to load unverified assemblies." + } + foreach ($pkg in $nupkgs) { + $output = & $NuGetExe verify -Signatures $pkg.FullName 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "NuGet signature verification failed for $($pkg.Name): $($output -join ' ')" + } + } +} + function Install-ParquetReader { [CmdletBinding()] param() @@ -45,17 +131,23 @@ function Install-ParquetReader { } if ($loaded) { return $true } - $parquetDir = Join-Path ([System.IO.Path]::GetTempPath()) 'FinOpsMultitool-Parquet' - $markerFile = Join-Path $parquetDir '.installed' + $parquetDir = Get-FinOpsParquetCachePath + $manifestFile = Join-Path $parquetDir 'parquet-manifest.json' - # If all DLLs were previously installed, just load them - if (Test-Path $markerFile) { - try { - Import-ParquetAssemblies -BasePath $parquetDir - return $true + # Reuse a cached install only when every DLL still matches the hash recorded + # at install time. + if (Test-Path -LiteralPath $manifestFile) { + if (Test-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile) { + try { + Import-ParquetAssemblies -BasePath $parquetDir + return $true + } + catch { + Remove-Item $parquetDir -Recurse -Force -ErrorAction SilentlyContinue + } } - catch { - # Corrupt install — wipe and redo + else { + Write-Warning "Parquet cache failed integrity check - reinstalling." Remove-Item $parquetDir -Recurse -Force -ErrorAction SilentlyContinue } } @@ -93,6 +185,10 @@ function Install-ParquetReader { $pkgDir = Join-Path $parquetDir 'packages' & $nugetExe install Parquet.Net -Version 4.24.0 -OutputDirectory $pkgDir -Framework net8.0 2>&1 | Out-Null + # Verify nuget.org signatures on the fetched packages before any of + # their assemblies are copied or loaded into this process. + Assert-NuGetPackageSignature -NuGetExe $nugetExe -PackageDir $pkgDir + # Copy managed DLLs to flat directory (prefer net8.0 > net6.0 > netstandard2.0) $libDir = Join-Path $parquetDir 'lib' New-Item -ItemType Directory -Path $libDir -Force | Out-Null @@ -124,8 +220,9 @@ function Install-ParquetReader { } } - # Write marker so next session skips the nuget step - 'installed' | Set-Content $markerFile + # Record hashes of everything we just staged so later loads can detect + # tampering instead of trusting a bare marker file. + New-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile Import-ParquetAssemblies -BasePath $parquetDir Write-Host " Parquet reader installed." -ForegroundColor DarkGray diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 index 79ce8d07a..e621ae092 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 @@ -1,6 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# Escapes a caller-supplied value for safe use inside a single-quoted KQL +# string literal. KQL uses backslash escapes, so \ and ' must both be escaped +# or a crafted value could terminate the literal and alter query semantics. +function ConvertTo-KqlLiteral { + param([string]$Value) + if ($null -eq $Value) { return '' } + return $Value.Replace('\', '\\').Replace("'", "\'") +} + function Search-AzGraphSafe { param( [Parameter(Mandatory)][string]$Query, @@ -34,6 +43,7 @@ function Search-AzGraphSafe { $result = $null $is429 = $false + $isTransient = $false if ($asyncResult.IsCompleted) { try { $raw = $ps.EndInvoke($asyncResult) @@ -53,11 +63,13 @@ function Search-AzGraphSafe { if ($ps.Streams.Error.Count -gt 0) { $errMsg = $ps.Streams.Error[0].Exception.Message if ($errMsg -match '429|throttl|Too Many Requests') { $is429 = $true; $result = $null } + elseif ($errMsg -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true; $result = $null } elseif (-not $result) { throw $ps.Streams.Error[0].Exception } } } catch { if ($_.Exception.Message -match '429|throttl|Too Many Requests') { $is429 = $true } + elseif ($_.Exception.Message -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true } else { $ps.Dispose(); throw } } } @@ -68,11 +80,22 @@ function Search-AzGraphSafe { $ps.Dispose() - if (-not $is429) { return $result } + if (-not ($is429 -or $isTransient)) { return $result } + if ($attempt -eq $MaxRetries) { 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......' } + # Throttling backs off harder than a transient server error. + $retryAfter = if ($is429) { + [math]::Min(10 * [math]::Pow(2, $attempt), 30) + } + else { + [math]::Min(2 * [math]::Pow(2, $attempt), 15) + } + $friendly = if ($is429) { + if (Get-Command Get-NextThrottleMessage -ErrorAction SilentlyContinue) { Get-NextThrottleMessage } else { 'Fetching numbers......' } + } + else { + 'Resource Graph is unavailable - retrying...' + } Write-Host " $friendly" -ForegroundColor Yellow if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { Update-ScanStatus $friendly diff --git a/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 b/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 new file mode 100644 index 000000000..37c106fbc --- /dev/null +++ b/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Runs the MCP protocol harness under Pester so it executes in CI +# (Test.PowerShell.All) instead of only when someone remembers to run it by hand. +# Protocol-only: -SkipLive means no Azure session or Az modules are needed. + +Describe 'FinOps Multitool MCP server protocol' { + + BeforeAll { + $script:harness = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/Test-McpServer.ps1' + $script:shell = (Get-Process -Id $PID).Path + } + + It 'Should ship the protocol test harness' { + Test-Path -LiteralPath $script:harness | Should -BeTrue + } + + It 'Should pass every protocol assertion' { + # Child process so the harness owns its own stdio for the JSON-RPC loop. + $out = & $script:shell -NoProfile -NonInteractive -File $script:harness -SkipLive 2>&1 + $code = $LASTEXITCODE + if ($code -ne 0) { Write-Host ($out | Out-String) } + $code | Should -Be 0 + ($out | Out-String) | Should -Match '0 failed' + } +} diff --git a/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 b/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 new file mode 100644 index 000000000..fc76c244f --- /dev/null +++ b/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 @@ -0,0 +1,218 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# The write-safety gate is a private FinOpsMultitool helper that the toolkit +# module only lazy-loads behind the TUI, so dot-source it directly. It must go in +# BeforeAll: top-level code runs only during Pester discovery, so functions +# dot-sourced there are gone by the run phase. +BeforeAll { + . "$PSScriptRoot/../../Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1" +} + +Describe 'FinOps Multitool write-safety gate' { + + BeforeEach { + # Every case starts from a known policy and empty in-process state. + $env:FINOPS_AUDIT_LOG = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-writegate-$([guid]::NewGuid().ToString('N')).log" + $env:FINOPS_WRITE_MAX_IMPACT = $null + $env:FINOPS_WRITE_MAX_PER_WINDOW = $null + $env:FINOPS_PROTECTED_TAGS = $null + $env:FINOPS_PROTECTED_RGS = $null + $env:FINOPS_PROTECTED_SUBS = $null + # Note: the gate's token store and write history are per-process and + # cannot be reset from here, so each case must stand on its own. + } + + AfterEach { + if ($env:FINOPS_AUDIT_LOG -and (Test-Path -LiteralPath $env:FINOPS_AUDIT_LOG)) { + Remove-Item -LiteralPath $env:FINOPS_AUDIT_LOG -Force -ErrorAction SilentlyContinue + } + $env:FINOPS_WRITE_MODE = $null + $env:FINOPS_AUDIT_LOG = $null + } + + Context 'ReadOnly mode (the default)' { + + It 'Should default to ReadOnly when FINOPS_WRITE_MODE is unset' { + $env:FINOPS_WRITE_MODE = $null + (Initialize-FinOpsWritePolicy).Mode | Should -Be 'ReadOnly' + } + + It 'Should fall back to ReadOnly when FINOPS_WRITE_MODE is not a known value' { + $env:FINOPS_WRITE_MODE = 'YOLO' + (Initialize-FinOpsWritePolicy).Mode | Should -Be 'ReadOnly' + } + + It 'Should block a dry-run preview' { + $env:FINOPS_WRITE_MODE = 'ReadOnly' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' + $d.Decision | Should -Be 'Blocked' + } + + It 'Should block apply=true' { + $env:FINOPS_WRITE_MODE = 'ReadOnly' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply + $d.Decision | Should -Be 'Blocked' + $d.Reason | Should -Match 'ReadOnly' + } + } + + Context 'Interactive mode' { + + BeforeEach { + $env:FINOPS_WRITE_MODE = 'Interactive' + Initialize-FinOpsWritePolicy | Out-Null + } + + It 'Should preview and issue a token on a dry run' { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' + $d.Decision | Should -Be 'Preview' + $d.ConfirmationToken | Should -Not -BeNullOrEmpty + $d.RequiresToken | Should -BeFalse + } + + It 'Should proceed on apply=true without a token' { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply + $d.Decision | Should -Be 'Proceed' + } + } + + Context 'Enforced mode' { + + BeforeEach { + $env:FINOPS_WRITE_MODE = 'Enforced' + Initialize-FinOpsWritePolicy | Out-Null + } + + It 'Should flag that a token is required on preview' { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' + $d.Decision | Should -Be 'Preview' + $d.RequiresToken | Should -BeTrue + $d.ConfirmationToken | Should -Not -BeNullOrEmpty + } + + It 'Should block apply=true with no token' { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply + $d.Decision | Should -Be 'Blocked' + $d.Reason | Should -Match 'Enforced' + } + + It 'Should block apply=true with a bogus token' { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken 'not-a-real-token' + $d.Decision | Should -Be 'Blocked' + } + + It 'Should proceed with the token from the matching dry run' { + $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' + $apply = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken + $apply.Decision | Should -Be 'Proceed' + } + + It 'Should reject a token on second use' { + $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' + $first = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken + $second = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken + $first.Decision | Should -Be 'Proceed' + $second.Decision | Should -Be 'Blocked' + } + + It 'Should reject a token issued for a different resource' { + $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/resource-A' + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/resource-B' -Apply -ConfirmationToken $preview.ConfirmationToken + $d.Decision | Should -Be 'Blocked' + } + + It 'Should reject a token issued for a different operation' { + $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Deallocate' -ResourceId '/subscriptions/s/rg/r' + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply -ConfirmationToken $preview.ConfirmationToken + $d.Decision | Should -Be 'Blocked' + } + } + + Context 'Guardrails' { + + BeforeEach { + $env:FINOPS_WRITE_MODE = 'Interactive' + } + + It 'Should block a resource carrying a protected tag by default' { + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Tags @{ 'DoNotDelete' = 'true' } -Apply + $d.Decision | Should -Be 'Blocked' + $d.GuardrailViolations.Count | Should -BeGreaterThan 0 + } + + It 'Should block a protected subscription' { + $env:FINOPS_PROTECTED_SUBS = '00000000-0000-0000-0000-000000000001' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -SubscriptionId '00000000-0000-0000-0000-000000000001' -Apply + $d.Decision | Should -Be 'Blocked' + } + + It 'Should block a resource group matching a protected pattern' { + $env:FINOPS_PROTECTED_RGS = 'prod-*' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -ResourceGroup 'prod-payments' -Apply + $d.Decision | Should -Be 'Blocked' + } + + It 'Should block when estimated impact exceeds the cap' { + $env:FINOPS_WRITE_MAX_IMPACT = '100' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -EstimatedMonthlyImpact 500 -Apply + $d.Decision | Should -Be 'Blocked' + } + + It 'Should allow an impact below the cap' { + $env:FINOPS_WRITE_MAX_IMPACT = '100' + Initialize-FinOpsWritePolicy | Out-Null + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -EstimatedMonthlyImpact 5 -Apply + $d.Decision | Should -Be 'Proceed' + } + + It 'Should block once the blast-radius cap is reached' { + $env:FINOPS_WRITE_MAX_PER_WINDOW = '3' + Initialize-FinOpsWritePolicy | Out-Null + # Write history is per-process and shared across cases, so assert the + # transition to Blocked rather than a fixed attempt number. + $blocked = $null + for ($n = 1; $n -le 6; $n++) { + $d = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId "/subscriptions/s/rg/r$n" -Apply + if ($d.Decision -eq 'Blocked') { $blocked = $d; break } + } + $blocked | Should -Not -BeNullOrEmpty + ($blocked.GuardrailViolations -join ' ') | Should -Match 'Blast-radius' + } + + It 'Should enforce guardrails even in Enforced mode with a valid token' { + $env:FINOPS_WRITE_MODE = 'Enforced' + $env:FINOPS_PROTECTED_RGS = 'prod-*' + Initialize-FinOpsWritePolicy | Out-Null + $preview = Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -ResourceGroup 'prod-payments' + $preview.Decision | Should -Be 'Blocked' + } + } + + Context 'Audit trail' { + + It 'Should append an entry for a blocked write' { + $env:FINOPS_WRITE_MODE = 'ReadOnly' + Initialize-FinOpsWritePolicy | Out-Null + Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply | Out-Null + Test-Path -LiteralPath $env:FINOPS_AUDIT_LOG | Should -BeTrue + (Get-Content -LiteralPath $env:FINOPS_AUDIT_LOG -Raw) | Should -Match 'blocked' + } + + It 'Should record preview and apply events' { + $env:FINOPS_WRITE_MODE = 'Interactive' + Initialize-FinOpsWritePolicy | Out-Null + Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' | Out-Null + Resolve-WriteDecision -ToolName 'test' -Operation 'Delete' -ResourceId '/subscriptions/s/rg/r' -Apply | Out-Null + $log = Get-Content -LiteralPath $env:FINOPS_AUDIT_LOG -Raw + $log | Should -Match 'preview' + $log | Should -Match 'apply' + } + } +} diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 index 5d4ab7a56..4caa7258f 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 @@ -32,7 +32,33 @@ InModuleScope 'FinOpsToolkit' { 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 + # Exact count so a deleted scanner fails the build instead of + # silently passing a loose lower bound. + $modules.Count | Should -Be 37 + } + + It 'Should dot-source every scanner module file from the loader' { + $psm1Path = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + $modulesPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/modules' + $loader = Get-Content -Path $psm1Path -Raw + foreach ($m in (Get-ChildItem -Path $modulesPath -Filter '*.ps1')) { + $loader | Should -Match ([regex]::Escape($m.Name)) + } + } + } + + Context 'Behavior' { + It 'Should write an error when the TUI launcher is missing' { + Mock Test-Path { $false } + { Start-FinOpsMultitool -ErrorAction Stop } | Should -Throw '*installation may be incomplete*' + } + + It 'Should not attempt to launch the TUI when the launcher is missing' { + Mock Test-Path { $false } + # Returns instead of dot-sourcing; a throw here would be a + # CommandNotFoundException for the never-loaded TUI function. + Start-FinOpsMultitool -ErrorAction SilentlyContinue + Should -Invoke Test-Path -Times 1 -Exactly } } From 959ac15731e2abe14b8e13ab2e8dce3bf8ebb181 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 17 Aug 2026 18:50:00 -0600 Subject: [PATCH 68/79] Update FinOps multitool --- .../Private/FinOpsMultitool/LICENSE | 21 ------------------- .../FinOpsMultitool/Start-McpServer.ps1 | 11 +++++++--- .../FinOpsMultitool/Test-McpServer.ps1 | 6 ++++-- .../modules/Enable-HybridBenefit.ps1 | 5 ++++- .../modules/Get-AHBOpportunities.ps1 | 5 ++++- .../modules/Get-AIWorkloadMetrics.ps1 | 5 ++++- .../modules/Get-AhbVmSavingsRatio.ps1 | 5 ++++- .../modules/Get-AnomalyAlerts.ps1 | 3 +++ .../modules/Get-BillingAccount.ps1 | 6 ++++-- .../modules/Get-BillingStructure.ps1 | 5 ++++- .../modules/Get-BudgetStatus.ps1 | 5 ++++- .../modules/Get-CarbonMetrics.ps1 | 5 ++++- .../modules/Get-CommitmentUtilization.ps1 | 5 ++++- .../modules/Get-ContractInfo.ps1 | 5 ++++- .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 5 ++++- .../FinOpsMultitool/modules/Get-CostData.ps1 | 5 ++++- .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 5 ++++- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 3 +++ .../modules/Get-LegacyResources.ps1 | 5 ++++- .../modules/Get-MaccCommitment.ps1 | 5 ++++- .../modules/Get-OptimizationAdvice.ps1 | 5 ++++- .../modules/Get-OrphanedResources.ps1 | 5 ++++- .../modules/Get-PolicyInventory.ps1 | 3 +++ .../modules/Get-PolicyRecommendations.ps1 | 3 +++ .../modules/Get-ReservationAdvice.ps1 | 5 ++++- .../modules/Get-ResourceCosts.ps1 | 5 ++++- .../modules/Get-SavingsRealized.ps1 | 5 ++++- .../modules/Get-SharedCostAllocation.ps1 | 5 ++++- .../modules/Get-StorageTierAdvice.ps1 | 3 +++ .../modules/Get-TagInventory.ps1 | 5 ++++- .../modules/Get-TagRecommendations.ps1 | 5 ++++- .../modules/Get-TenantHierarchy.ps1 | 5 ++++- .../modules/Get-UnitEconomics.ps1 | 5 ++++- .../Get-UsageProportionalAllocation.ps1 | 6 ++++-- .../modules/Get-VmCostBreakdown.ps1 | 5 ++++- .../modules/Initialize-Scanner.ps1 | 5 ++++- .../modules/New-PowerBITemplate.ps1 | 6 ++++-- .../modules/Remove-OrphanedResource.ps1 | 5 ++++- .../modules/Set-CostAllocationRule.ps1 | 6 ++++-- .../FinOpsMultitool/modules/Stop-IdleVm.ps1 | 5 ++++- .../modules/helpers/Confirm-WriteAction.ps1 | 5 ++++- .../modules/helpers/Get-CostExport.ps1 | 1 - .../modules/helpers/Get-FOHubProvider.ps1 | 1 - .../modules/helpers/Get-KpiInsights.ps1 | 6 ++++-- .../helpers/Invoke-FOHubKustoQuery.ps1 | 1 - .../modules/helpers/Read-FinOpsHubData.ps1 | 6 ++++-- .../helpers/Resolve-CostDataSource.ps1 | 6 ++++-- 47 files changed, 171 insertions(+), 72 deletions(-) delete mode 100644 src/powershell/Private/FinOpsMultitool/LICENSE diff --git a/src/powershell/Private/FinOpsMultitool/LICENSE b/src/powershell/Private/FinOpsMultitool/LICENSE deleted file mode 100644 index dd0ab5585..000000000 --- a/src/powershell/Private/FinOpsMultitool/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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/Start-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 index a91d4b398..cc2b385d2 100644 --- a/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 @@ -1,10 +1,12 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # 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: @@ -77,7 +79,10 @@ Import-Module $psm1Path -Force -DisableNameChecking # ===================================================================== $MCP_VERSION = '2024-11-05' $SERVER_NAME = 'finops-multitool' -$SERVER_VERSION = '1.3.0' + +# Reported server version tracks the toolkit release so the two cannot drift. +. (Join-Path $PSScriptRoot '..' 'Get-VersionNumber.ps1') +$SERVER_VERSION = Get-VersionNumber # ===================================================================== # TOOL DEFINITIONS diff --git a/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 index 30df77750..9899ca87d 100644 --- a/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # TEST-MCPSERVER.PS1 # FINOPS MULTITOOL MCP SERVER TEST HARNESS ########################################################################### @@ -6,7 +9,6 @@ # 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: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 index 60d61280f..fc7f70e31 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # ENABLE-HYBRIDBENEFIT.PS1 # AZURE FINOPS MULTITOOL - Enable Azure Hybrid Benefit on a VM ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 index ca615e327..86c80ec03 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-AHBOPPORTUNITIES.PS1 # AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 5a4236375..ed1deb7cd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-AIWORKLOADMETRICS.PS1 # AZURE FINOPS MULTITOOL - AI/LLM Workload KPIs (token economics) ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 index f122055fb..b8149695c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-AHBVMSAVINGSRATIO.PS1 # AZURE FINOPS MULTITOOL - Per-SKU Azure Hybrid Benefit Savings Ratio ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 index 3c2504fbf..df4915a94 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ########################################################################### # GET-ANOMALYALERTS.PS1 # AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 index cd88a0ba9..6282f274f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -1,11 +1,13 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-BILLINGACCOUNT.PS1 # DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY ########################################################################### # Purpose: Read-only lookup of billing accounts you can see, their agreement # type, whether they support cost allocation rules, and whether you # can reach the rules endpoint (Cost Management Contributor signal). -# Author: Zac Larsen # Date: Created for FinOps Multitool shared-cost allocation # # Description: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 index da5098263..a7bc2d51a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-BILLINGSTRUCTURE.PS1 # AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 2e49ee4bf..95f444743 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-BUDGETSTATUS.PS1 # AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 index a26c29d51..f8de8d949 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-CARBONMETRICS.PS1 # AZURE FINOPS MULTITOOL - Carbon Emissions (Sustainability) ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index a123c58d6..35d0d6eef 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-COMMITMENTUTILIZATION.PS1 # AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 index e869e3f5f..99eea7466 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-CONTRACTINFO.PS1 # AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 index 68e6941d9..60504f7ee 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-COSTBYTAG.PS1 # AZURE FINOPS MULTITOOL - Cost Breakdown by Tag ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index 9fce0a5eb..147413072 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-COSTDATA.PS1 # AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 index 8b30bccb9..4caeb7158 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-COSTTREND.PS1 # AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index 091be924c..fec7c73fa 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ########################################################################### # GET-IDLEVMS.PS1 # AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 index 7855ad3e6..694e110fe 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-LEGACYRESOURCES.PS1 # AZURE FINOPS MULTITOOL - Legacy & Retiring Resource Detection ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 index d42ef081d..122333278 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-MACCCOMMITMENT.PS1 # AZURE FINOPS MULTITOOL - MACC Consumption Commitment Tracking ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 index 21865787b..b5347b5d7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-OPTIMIZATIONADVICE.PS1 # AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index 761034ee3..c2402202e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-ORPHANEDRESOURCES.PS1 # AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index 29fe34da3..fdea7c8f9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ########################################################################### # GET-POLICYINVENTORY.PS1 # AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 index 32f59208a..c622f0916 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ########################################################################### # GET-POLICYRECOMMENDATIONS.PS1 # AZURE FINOPS MULTITOOL - FinOps Policy Recommendations diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 index 3ca656525..bbfa6b38b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-RESERVATIONADVICE.PS1 # AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 index 7e6f9e4bf..d7c18a23f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-RESOURCECOSTS.PS1 # AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index 17db7ba4d..ee9482330 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-SAVINGSREALIZED.PS1 # AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index e59c0cd84..212125107 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-SHAREDCOSTALLOCATION.PS1 # AZURE FINOPS MULTITOOL - Shared Hub Cost Allocation (Showback) ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index 17ff0e956..095ff40b1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ########################################################################### # GET-STORAGETIERADVICE.PS1 # AZURE FINOPS MULTITOOL - Storage Tier Optimization diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 index 2f9849a77..9de999c94 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-TAGINVENTORY.PS1 # AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 index 54e776c99..c04833dfe 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-TAGRECOMMENDATIONS.PS1 # AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices) ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 index c536c6ede..fadba3449 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-TENANTHIERARCHY.PS1 # AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index e70c00396..806ca9f51 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-UNITECONOMICS.PS1 # AZURE FINOPS MULTITOOL - Unit Economics ($/vCPU, $/GB) ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 index 085e574eb..53e9e2652 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -1,11 +1,13 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-USAGEPROPORTIONALALLOCATION.PS1 # TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI) ########################################################################### # Purpose: Split the billed cost of a shared platform across SUB-RESOURCE # consumers (k8s namespace, APIM product, OpenAI deployment) by a # usage signal pulled from telemetry - for showback/chargeback. -# Author: Zac Larsen # Date: Created for FinOps Multitool shared-cost allocation # # Description: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index 0ded37666..ac8fa0584 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-VMCOSTBREAKDOWN.PS1 # AZURE FINOPS MULTITOOL - Full VM Cost Decomposition ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 index 8bbad1183..d5c579505 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # INITIALIZE-SCANNER.PS1 # AZURE FINOPS MULTITOOL - Authentication & Prerequisites ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 index 6f64ec5bd..4d8f15404 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 @@ -1,9 +1,11 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # NEW-POWERBITEMPLATE.PS1 # POWER BI TEMPLATE GENERATOR (GUI-FREE) ########################################################################### # Purpose: Build a Power BI template (.pbit) from FinOps scan data. -# Author: Zac Larsen # Date: Created for FinOps Toolkit integration # # Description: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 index 25d6356b0..d3910bf5e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # REMOVE-ORPHANEDRESOURCE.PS1 # AZURE FINOPS MULTITOOL - Safe Deletion of Orphaned Resources ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 index 42f673298..8d2560961 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 @@ -1,10 +1,12 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # SET-COSTALLOCATIONRULE.PS1 # WRITE-BACK: NATIVE AZURE COST ALLOCATION RULE (CHARGEBACK) ########################################################################### # Purpose: Push transfer-weighted percentages into a native Azure Cost # Management cost allocation rule so chargeback reflects the split. -# Author: Zac Larsen # Date: Created for FinOps Multitool shared-cost allocation # # Description: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 index d5520dd4f..b7b31e68e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # STOP-IDLEVM.PS1 # AZURE FINOPS MULTITOOL - Deallocate an Idle VM ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 index 3d9b416f6..2a6d0c511 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # CONFIRM-WRITEACTION.PS1 # AZURE FINOPS MULTITOOL - Configurable Write-Safety Core ########################################################################### diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index fb0236c93..55a51102c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -9,7 +9,6 @@ # 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: diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 43653f28f..75ecc334e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -10,7 +10,6 @@ # 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: diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 0032e4348..aa9ec4468 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -1,4 +1,7 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # GET-KPIINSIGHTS.PS1 # FINOPS KPI CORRELATION LAYER ########################################################################### @@ -6,7 +9,6 @@ # (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: diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 index f363467f3..01c85e016 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 @@ -9,7 +9,6 @@ # 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: diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index d406f2595..80d3ddc51 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -1,9 +1,11 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # READ-FINOPSHUBDATA.PS1 # FINOPS HUB STORAGE DATA READER ########################################################################### # Purpose: Read cost data from a FinOps Hub storage account -# Author: Zac Larsen # Date: Created for FinOps Multitool TUI integration # # Description: diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 index 23fb2ce59..fd6758c2a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 @@ -1,10 +1,12 @@ -########################################################################### +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### # RESOLVE-COSTDATASOURCE.PS1 # COST DATA SOURCE RESOLVER (EXPORT-FIRST ROUTING) ########################################################################### # Purpose: Decide whether cost scans should read FinOps Hub / Cost # Management export data (fast) or the live Cost Management API. -# Author: Zac Larsen # Date: Created for FinOps Multitool MCP server export-first routing # # Description: From a3ad5022a510f36a5c06dd81b3b70825e1036cb4 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 17 Aug 2026 19:40:06 -0600 Subject: [PATCH 69/79] Update FinOps multitool --- .../Invoke-FinOpsMultitool.ps1 | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 6466104ca..abe1a5876 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -166,6 +166,20 @@ function Invoke-FinOpsMultitool { Write-Host $banner -ForegroundColor Cyan } + # ===================================================================== + # CONSOLE HELPERS + # ===================================================================== + # Menu rows must never reach the console width. A row that wraps occupies two + # physical lines, which desynchronizes the cursor-up math the pickers use to + # redraw in place and makes the menu smear down the screen. + function Get-MenuWidth { + param([int]$Cap) + $consoleWidth = 0 + try { $consoleWidth = [Console]::WindowWidth } catch { $consoleWidth = 0 } + if ($consoleWidth -lt 20) { return $Cap } + return [math]::Min($Cap, $consoleWidth - 1) + } + # ===================================================================== # DATA SOURCE PICKER # ===================================================================== @@ -305,6 +319,7 @@ function Invoke-FinOpsMultitool { } while ($true) { + $tWidth = Get-MenuWidth 85 [Console]::SetCursorPosition(0, [Console]::CursorTop) for ($t = 0; $t -lt $tenants.Count; $t++) { $tPrefix = if ($t -eq $tCursor) { ' > ' } else { ' ' } @@ -315,8 +330,8 @@ function Invoke-FinOpsMultitool { 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 + if ($tLine.Length -gt $tWidth) { $tLine = $tLine.Substring(0, $tWidth - 3) + '...' } + Write-Host $tLine.PadRight($tWidth) -ForegroundColor $tColor } Write-Host "" Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray @@ -350,7 +365,7 @@ function Invoke-FinOpsMultitool { # Move cursor back up to re-render $tLinesToClear = $tenants.Count + 2 - [Console]::SetCursorPosition(0, [Console]::CursorTop - $tLinesToClear) + [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $tLinesToClear)) } Write-Host "" } @@ -406,14 +421,15 @@ function Invoke-FinOpsMultitool { # Render list $renderStart = $offset $renderEnd = [math]::Min($offset + $pageSize, $allSubs.Count) - 1 + $width = Get-MenuWidth 75 [Console]::SetCursorPosition(0, [Console]::CursorTop) for ($i = $renderStart; $i -le $renderEnd; $i++) { $prefix = if ($i -eq $cursor) { ' > ' } else { ' ' } $color = if ($i -eq $cursor) { 'Green' } else { 'Gray' } $line = "$prefix$($allSubs[$i].Name)" - if ($line.Length -gt 70) { $line = $line.Substring(0, 67) + '...' } - Write-Host $line.PadRight(75) -ForegroundColor $color + if ($line.Length -gt $width) { $line = $line.Substring(0, $width - 3) + '...' } + Write-Host $line.PadRight($width) -ForegroundColor $color } Write-Host "" Write-Host " ↑↓ Navigate │ Enter = Select │ Q = Cancel" -ForegroundColor DarkGray @@ -441,7 +457,7 @@ function Invoke-FinOpsMultitool { # Move cursor back up to re-render $linesToClear = ($renderEnd - $renderStart + 1) + 2 - [Console]::SetCursorPosition(0, [Console]::CursorTop - $linesToClear) + [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $linesToClear)) } } From 59cf0f182b260d2f9f45928fe524e8e6a00771b3 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 17 Aug 2026 20:33:55 -0600 Subject: [PATCH 70/79] Update FinOps multitool --- .../FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index abe1a5876..df6fb4690 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -140,6 +140,17 @@ function Invoke-FinOpsMultitool { # ===================================================================== function Show-Banner { Clear-Host + + # Version comes from the toolkit so the TUI, MCP server, and module cannot drift. + # Get-VersionNumber is a sibling private function, absent when this script runs standalone. + if (-not (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue)) { + $verFile = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'Get-VersionNumber.ps1' + if (Test-Path -Path $verFile) { . $verFile } + } + $verText = if (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue) { "v$(Get-VersionNumber)" } else { '' } + # Keeps the banner box interior at a fixed 72 characters for any version length. + $verPad = ' ' * [math]::Max(1, 32 - $verText.Length) + $banner = @" ╔════════════════════════════════════════════════════════════════════════╗ @@ -158,7 +169,7 @@ function Invoke-FinOpsMultitool { ║ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗ ║ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ║ ║ - ║ Azure FinOps Scanner & Optimizer v2.3.0 ║ + ║ Azure FinOps Scanner & Optimizer$verPad$verText ║ ║ ║ ╚════════════════════════════════════════════════════════════════════════╝ From caf7d5d5ab466257dc80ab335ea0ac3dcc28fd28 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 19 Aug 2026 19:50:05 -0600 Subject: [PATCH 71/79] Update FinOps multitool --- .vscode/mcp.json | 12 - docs-mslearn/toolkit/changelog.md | 7 +- .../multitool/finops-multitool-overview.md | 6 +- .../multitool/finops-multitool-commands.md | 10 +- docs/multitool.md | 10 +- .../FinOpsMultitool/FinOpsMultitool.psm1 | 2 +- .../Invoke-FinOpsMultitool.ps1 | 4 +- .../Private/FinOpsMultitool/README.md | 142 +- .../FinOpsMultitool/Start-McpServer.ps1 | 1802 ----------------- .../FinOpsMultitool/Test-McpServer.ps1 | 228 --- .../FinOpsMultitool/kpi/kpi-catalog.json | 2 +- .../modules/Get-SharedCostAllocation.ps1 | 2 +- .../modules/Get-VmCostBreakdown.ps1 | 2 +- .../modules/New-PowerBITemplate.ps1 | 4 +- .../modules/helpers/Get-CostExport.ps1 | 6 +- .../modules/helpers/Get-KpiInsights.ps1 | 12 +- .../helpers/Resolve-CostDataSource.ps1 | 10 +- .../Unit/FinOpsMultitool.McpServer.Tests.ps1 | 27 - .../anomaly-investigation/SKILL.md | 8 +- .../azure-policy-governance/SKILL.md | 6 +- .../azure-workbooks-finops/SKILL.md | 2 +- .../agent-skills/cost-allocation/SKILL.md | 40 +- .../agent-skills/cost-data-source/SKILL.md | 16 +- .../agent-skills/finops-multitool/SKILL.md | 209 +- .../references/commitments.md | 90 + .../references/cost-analysis.md | 94 + .../references/tags-and-policy.md | 123 ++ .../references/waste-detection.md | 208 ++ .../agent-skills/finops-reporting/SKILL.md | 10 +- .../agent-skills/focus-data-quality/SKILL.md | 2 +- .../forecasting-budgeting/SKILL.md | 6 +- .../agent-skills/power-bi-finops/SKILL.md | 50 +- .../rate-optimization-portfolio/SKILL.md | 8 +- .../sustainability-carbon/SKILL.md | 8 +- .../agent-skills/unit-economics/SKILL.md | 12 +- .../skills/anomaly-investigation | 1 + .../skills/azure-policy-governance | 1 + .../skills/azure-workbooks-finops | 1 + .../claude-plugin/skills/cost-allocation | 1 + .../claude-plugin/skills/cost-data-source | 1 + .../claude-plugin/skills/finops-multitool | 1 + .../claude-plugin/skills/finops-reporting | 1 + .../claude-plugin/skills/focus-data-quality | 1 + .../skills/forecasting-budgeting | 1 + .../claude-plugin/skills/power-bi-finops | 1 + .../skills/rate-optimization-portfolio | 1 + .../skills/sustainability-carbon | 1 + .../claude-plugin/skills/unit-economics | 1 + 48 files changed, 740 insertions(+), 2453 deletions(-) delete mode 100644 .vscode/mcp.json delete mode 100644 src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 delete mode 100644 src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 create mode 100644 src/templates/agent-skills/finops-multitool/references/commitments.md create mode 100644 src/templates/agent-skills/finops-multitool/references/cost-analysis.md create mode 100644 src/templates/agent-skills/finops-multitool/references/tags-and-policy.md create mode 100644 src/templates/agent-skills/finops-multitool/references/waste-detection.md create mode 120000 src/templates/claude-plugin/skills/anomaly-investigation create mode 120000 src/templates/claude-plugin/skills/azure-policy-governance create mode 120000 src/templates/claude-plugin/skills/azure-workbooks-finops create mode 120000 src/templates/claude-plugin/skills/cost-allocation create mode 120000 src/templates/claude-plugin/skills/cost-data-source create mode 120000 src/templates/claude-plugin/skills/finops-multitool create mode 120000 src/templates/claude-plugin/skills/finops-reporting create mode 120000 src/templates/claude-plugin/skills/focus-data-quality create mode 120000 src/templates/claude-plugin/skills/forecasting-budgeting create mode 120000 src/templates/claude-plugin/skills/power-bi-finops create mode 120000 src/templates/claude-plugin/skills/rate-optimization-portfolio create mode 120000 src/templates/claude-plugin/skills/sustainability-carbon create mode 120000 src/templates/claude-plugin/skills/unit-economics diff --git a/.vscode/mcp.json b/.vscode/mcp.json deleted file mode 100644 index 8e29de3de..000000000 --- a/.vscode/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "servers": { - "finops-multitool": { - "type": "stdio", - "command": "pwsh", - "args": ["-NoProfile", "-File", "${workspaceFolder}/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1"], - "env": { - "FINOPS_WRITE_MODE": "ReadOnly" - } - } - } -} diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 9869e7503..d621c4c5d 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 07/02/2026 +ms.date: 08/19/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -35,11 +35,10 @@ The following section lists features and enhancements that are currently in deve ### [FinOps multitool](multitool/finops-multitool-overview.md) v15.0.0 - **Added** - - Added the FinOps multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a cross-platform terminal UI and an MCP server for AI agents ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Added the FinOps multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a cross-platform terminal UI ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). - Includes 30 read-only scan modules covering orphaned resources, idle VMs, storage tier advice, Azure Hybrid Benefit, tag and policy inventory and recommendations, cost data, cost trend, cost by tag, resource costs, reservation advice, commitment utilization, realized savings, budget status, anomaly alerts, Advisor recommendations, billing structure, and contract info. - - The MCP server exposes 40 tools (36 read-only and 4 gated write/remediation) over the Model Context Protocol, with a configurable write-safety policy that defaults to read-only. + - Added a companion set of agent skills that carry the investigation routing, the queries, and the interpretation rules so AI agents can run the same analysis through Azure CLI or an Azure MCP server. - Cost scans prefer the FinOps hub's Azure Data Explorer or Microsoft Fabric Kusto database and push aggregation into the engine to scale to large environments, with a storage reader as a small-dataset fallback. - - Added a companion set of agent skills that teach AI agents to use the server and route findings into the wider FinOps practice. ### Bicep Registry module pending updates diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index 6c41973a2..03e7edbad 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -1,9 +1,9 @@ --- title: FinOps multitool overview -description: FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights from a terminal UI or an MCP server for AI agents. +description: FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights from a terminal UI, with agent skills so AI assistants can run the same analysis. author: z-larsen ms.author: zlarsen -ms.date: 08/13/2026 +ms.date: 08/19/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -21,7 +21,7 @@ FinOps multitool runs 30 scan modules against the subscriptions you select and r - **Interactive scanning**
Choose the subscriptions and scan modules you want, then review results in the terminal. Findings can be exported to CSV, an HTML report, and a text summary. -- **AI agent support**
A Model Context Protocol (MCP) server exposes the same scans as tools, so agents like GitHub Copilot can answer cost questions grounded in your environment instead of general guidance. +- **AI agent support**
A companion set of agent skills teaches AI assistants the same investigations, the queries behind them, and how to read the results, so they can answer cost questions grounded in your environment instead of general guidance. - **Scales with your data**
When a [FinOps hub](../hubs/finops-hubs-overview.md) is available, cost scans query the hub's Azure Data Explorer or Microsoft Fabric database and push aggregation into the engine, returning only summarized results. A storage reader covers smaller datasets, and the Cost Management API is used when no hub is present. diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 8a0203747..ae8b011a1 100644 --- a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md +++ b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md @@ -3,7 +3,7 @@ title: FinOps multitool commands description: Learn about PowerShell commands in the FinOpsToolkit module that scan an Azure environment for cost optimization, governance, and FinOps insights. author: z-larsen ms.author: zlarsen -ms.date: 07/02/2026 +ms.date: 08/19/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -18,7 +18,7 @@ The FinOps multitool PowerShell commands help you scan an Azure environment for The Multitool delivers one scan engine through two interfaces: - **Terminal UI (TUI)** – An interactive, cross-platform terminal experience launched with [Start-FinOpsMultitool](Start-FinOpsMultitool.md). It surfaces 26 of the 30 scans. -- **MCP server** – A Model Context Protocol server (`Start-McpServer.ps1`) that exposes all 30 scans as tools for AI agents like GitHub Copilot. +- **Agent skills** - A set of skills that teach AI assistants which investigation answers a question, the queries behind it, and how to read the results.
@@ -57,11 +57,11 @@ If no hub is available, cost scans use the live Cost Management API.
-## MCP server for AI agents +## Agent skills -`Start-McpServer.ps1` exposes the scan engine as 40 tools over the Model Context Protocol (`2024-11-05`) via stdio: 36 read-only analysis tools (including `run_full_scan` and `detect_cost_data_source`) and four write/remediation tools. The write tools are dry-run by default, gated by a configurable write-safety policy, and disabled unless the `FINOPS_WRITE_MODE` environment variable is set—the server defaults to `ReadOnly`, which blocks all writes. +A companion set of agent skills carries the same analysis as guidance an AI agent can act on: which investigation answers the question, the Resource Graph and Cost Management queries behind it, and the places raw results mislead. Agents run the queries through Azure CLI or an Azure MCP server, so no additional server is required. -A companion set of agent skills teaches AI agents to use the server and route findings into the wider FinOps practice. The `finops-multitool` skill acts as the hub and hands off to FinOps-adjacent skills for reporting, allocation, governance, unit economics, and more. +The `finops-multitool` skill is the routing hub and hands off to FinOps-adjacent skills for reporting, allocation, governance, unit economics, and more. The skills are read-only by design—remediation stays in the terminal UI, where every write previews first and requires confirmation.
diff --git a/docs/multitool.md b/docs/multitool.md index 0f3b4b634..5f80fe26c 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -3,13 +3,13 @@ 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.' +description: 'The FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI, with agent skills so AI assistants can run the same analysis.' 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. +Scan your Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI, with agent skills so AI assistants can run the same analysis. {: .fs-6 .fw-300 } Install @@ -22,7 +22,7 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc

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. + The FinOps multitool is a new addition to the FinOps toolkit. It delivers 30 read-only scan modules through a cross-platform terminal UI, plus agent skills for AI assistants, with a scalable FinOps hub Kusto data path for large environments.

See all changes

@@ -38,8 +38,8 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc Learn more
-
🤖 MCP server
-
Expose the scan engine as tools for AI agents like GitHub Copilot over the Model Context Protocol.
+
🤖 Agent skills
+
Teach AI agents the FinOps investigations, the queries behind them, and how to read the results.
Learn more
diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index abd92c626..b50888d54 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -6,7 +6,7 @@ # 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. +# imported via Import-Module and used by the TUI. # # Usage: # Import-Module .\FinOpsMultitool.psm1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index df6fb4690..5e58f8780 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -141,7 +141,7 @@ function Invoke-FinOpsMultitool { function Show-Banner { Clear-Host - # Version comes from the toolkit so the TUI, MCP server, and module cannot drift. + # Version comes from the toolkit so the TUI and the module cannot drift. # Get-VersionNumber is a sibling private function, absent when this script runs standalone. if (-not (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue)) { $verFile = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'Get-VersionNumber.ps1' @@ -1565,7 +1565,7 @@ function Invoke-FinOpsMultitool { # -- 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). + # catalog + compute path in both entry points (parity). if (Get-Command Get-KpiInsightsForResult -ErrorAction SilentlyContinue) { $kpiInsights = @() try { $kpiInsights = @(Get-KpiInsightsForResult -FunctionName $mod.Fn -Output $data) } catch { $kpiInsights = @() } diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index bb3f4e0ad..b327b8b96 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -172,9 +172,9 @@ Each scan module requires specific Azure RBAC roles. The TUI will tell you which ## 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: +The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). Each scan answers a KPI question directly, and the `finops-multitool` agent skill routes a natural-language question to the matching investigation. A few examples of question → output: -### Percentage of Legacy Resource → `scan_legacy_resources` +### Percentage of Legacy Resource → legacy resources > "Which of my resources are running on legacy or retiring SKUs?" @@ -191,7 +191,7 @@ By category: Legacy % = 47 ÷ total resources in scope. -### Cost per Gigabyte Stored / Hourly Cost per CPU Core → `scan_unit_economics` +### Cost per Gigabyte Stored / Hourly Cost per CPU Core → unit economics > "What's my cost per vCPU and per GB of storage this month?" @@ -209,7 +209,7 @@ Storage $ 41,200 (24.3%) 126,400 GB (84,600 GB disk + 41,800 GB blob/file) 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` +### Token Consumption / Cost per 1K Tokens / Cost per API Call → ai workloads > "What are my AI/LLM workloads costing per token and per request this month?" @@ -229,7 +229,7 @@ Produces `Token Consumption`, `Cost per 1K Tokens` (effective blended rate), and 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` +### Carbon per Unit of Spend / Carbon Efficiency → carbon > "Show my cloud carbon footprint and how it changed month over month." @@ -245,9 +245,9 @@ Top emitting subscriptions: Data-Platform a1b2c3d4… 4,330 kgCO2e ``` -Combined with `scan_cost_data`, `Carbon per Unit of Spend` = total emissions ÷ monthly spend. +Combined with cost data, `Carbon per Unit of Spend` = total emissions ÷ monthly spend. -### Commitment Utilization Score / % Discount Waste → `scan_commitment_utilization` +### Commitment Utilization Score / % Discount Waste → commitment utilization > "How well are my reservations and savings plans being used?" @@ -261,7 +261,7 @@ 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` +### % Costs from Untagged Resources → cost by tag > "How much of my spend is on untagged resources?" @@ -294,7 +294,7 @@ The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOp | **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). +Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered cluster, else the storage reader. To force the live Cost Management API instead, choose the **Cost Management API** source in the TUI. #### Environment variables @@ -321,129 +321,9 @@ $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`. +The remediation functions are read-only by default. Every write previews first, and enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`. The gate lives in the functions themselves, so it applies to any caller — the TUI, a script, or anything that imports the module. ### Modes — `FINOPS_WRITE_MODE` @@ -477,7 +357,6 @@ These never depend on a well-behaved client. Configure via environment variables "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", @@ -497,7 +376,6 @@ 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) diff --git a/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 deleted file mode 100644 index cc2b385d2..000000000 --- a/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 +++ /dev/null @@ -1,1802 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# 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. -# 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' - -# Reported server version tracks the toolkit release so the two cannot drift. -. (Join-Path $PSScriptRoot '..' 'Get-VersionNumber.ps1') -$SERVER_VERSION = Get-VersionNumber - -# ===================================================================== -# 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 deleted file mode 100644 index 9899ca87d..000000000 --- a/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# 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. -# 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/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json index 5127d054d..115a27e25 100644 --- a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -1,5 +1,5 @@ { - "_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.", + "_comment": "FinOps Foundation KPI correlation catalog (Phase 1). Curated subset of https://www.finops.org/finops-kpis/ that the scan output can inform. Each entry maps one or more source tools to a KPI. 'compute' = the server can calculate a value; 'informational' = the scan relates to the KPI but the value needs the field below or external input. Keep this honest: never claim a value we cannot derive.", "version": "1.0.0", "learnMoreBase": "https://www.finops.org/finops-kpis/", "kpis": [ diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 212125107..610c9777a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -33,7 +33,7 @@ # 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). +# when -HubData is injected by the caller (fast path). # # Notes: # - RBAC: Cost Management Reader (hub + spoke subs) + Reader (ARG). diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index ac8fa0584..012b84192 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -18,7 +18,7 @@ # 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, +# when -HubData is injected by the caller (fast path, # also carries consumed quantity so egress GB is exact). # # Notes: diff --git a/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 index 4d8f15404..811b881f9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 @@ -9,7 +9,7 @@ # Date: Created for FinOps Toolkit integration # # Description: -# Headless, dependency-free generator used by the TUI and the MCP +# Headless, dependency-free generator used by the TUI and by # server. Given a scan-data hashtable it: # 1. Writes one curated CSV per scan section into the output folder. # 2. Clones assets\skeleton.pbit and injects a generated DataModelSchema so @@ -18,7 +18,7 @@ # # 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. +# like. Callers can invoke it directly. # # ── Parameters ────────────────────────────────────────────────── # ScanData Hashtable of scan results (Auth, Costs, ResourceCosts, diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index 55a51102c..b4b29b22b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -3,13 +3,13 @@ ########################################################################### # GET-COSTEXPORT.PS1 -# COST MANAGEMENT EXPORT DETECTION & FAST READ (TUI / MCP) +# COST MANAGEMENT EXPORT DETECTION & FAST READ ########################################################################### # Purpose: Detect existing Cost Management exports (any export, not just a -# FinOps Hub) and read their CSV data from blob storage so the MCP +# FinOps Hub) and read their CSV data from blob storage so the # server can serve cost tools from a pre-materialized export # instead of the throttle-bound live Cost Management query API. -# Date: Created for FinOps Multitool MCP generic export detection +# Date: Created for FinOps Multitool generic export detection # # Description: # Read-only port of the GUI scanner's export module. Supplies the same diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index aa9ec4468..538bb2bf6 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -6,13 +6,13 @@ # 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 +# (https://www.finops.org/finops-kpis/) so callers who do not # know the KPI taxonomy still see which industry KPIs their results # inform, with a computed value where the data allows. # Date: Created for KPI skills # # Description: -# Additive only. Does not change any scan. After a tool returns, the MCP +# Additive only. Does not change any scan. After a scan returns, the # server calls Add-KpiInsights to attach a kpiInsights[] block: # - status 'computed' a value was derived from the scan fields # - status 'informational' the scan relates to the KPI; explore to learn @@ -288,8 +288,8 @@ function Add-KpiInsights { } # 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. +# scan name the KPI catalog keys off (sourceTool). Lets every caller reuse the +# exact same compute path, so KPI behavior stays in parity. function Get-KpiToolNameForFunction { param([Parameter(Mandatory)][string]$FunctionName) $map = @{ @@ -317,8 +317,8 @@ function Get-KpiToolNameForFunction { } # 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 +# data, not a { tool; data } envelope). Wraps the output in the same +# envelope the catalog expects so Add-KpiInsights/Get-KpiComputedValue run the # identical logic. Returns an array of insight objects (possibly empty). function Get-KpiInsightsForResult { param( diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 index fd6758c2a..68b6d4855 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 @@ -7,10 +7,10 @@ ########################################################################### # Purpose: Decide whether cost scans should read FinOps Hub / Cost # Management export data (fast) or the live Cost Management API. -# Date: Created for FinOps Multitool MCP server export-first routing +# Date: Created for FinOps Multitool export-first routing # # Description: -# Non-interactive detector used by the MCP server and the agent skill. +# Non-interactive detector used by the TUI and the agent skills. # It inspects the requested scope and returns a structured decision so # the agent can take the fast path when an export is readable, or set # expectations (and ask) before falling back to the slow API path. @@ -143,7 +143,7 @@ Resources # 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 + # Attached here so it flows through every return path below and the # detect tool can advertise it without a second Resource Graph call. $cluster = Get-HubKustoCluster -RequestedSubscriptionIds $requested -HubResourceGroup $hub.resourceGroup if ($cluster -and $cluster.ClusterUri) { @@ -418,7 +418,7 @@ function Get-HubCoverage { # 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 +# counted), and reports coverage + freshness so the caller can take the # export fast path the same way it does for a hub. CSV only — Parquet # exports are detected and reported but not read in PowerShell. function Resolve-GenericExportSource { @@ -443,7 +443,7 @@ function Resolve-GenericExportSource { $subCount = $requested.Count # Find-CostExport needs subscription objects (Id + Name). The resolver - # only has IDs; names are not needed for detection (the MCP dispatch + # only has IDs; names are not needed for detection (the dispatch # supplies the real sub objects to the converters). $subObjs = $requested | ForEach-Object { [pscustomobject]@{ Id = $_; Name = $_ } } diff --git a/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 b/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 deleted file mode 100644 index 37c106fbc..000000000 --- a/src/powershell/Tests/Unit/FinOpsMultitool.McpServer.Tests.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Runs the MCP protocol harness under Pester so it executes in CI -# (Test.PowerShell.All) instead of only when someone remembers to run it by hand. -# Protocol-only: -SkipLive means no Azure session or Az modules are needed. - -Describe 'FinOps Multitool MCP server protocol' { - - BeforeAll { - $script:harness = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/Test-McpServer.ps1' - $script:shell = (Get-Process -Id $PID).Path - } - - It 'Should ship the protocol test harness' { - Test-Path -LiteralPath $script:harness | Should -BeTrue - } - - It 'Should pass every protocol assertion' { - # Child process so the harness owns its own stdio for the JSON-RPC loop. - $out = & $script:shell -NoProfile -NonInteractive -File $script:harness -SkipLive 2>&1 - $code = $LASTEXITCODE - if ($code -ne 0) { Write-Host ($out | Out-String) } - $code | Should -Be 0 - ($out | Out-String) | Should -Match '0 failed' - } -} diff --git a/src/templates/agent-skills/anomaly-investigation/SKILL.md b/src/templates/agent-skills/anomaly-investigation/SKILL.md index a3b17369d..cf1e4ab20 100644 --- a/src/templates/agent-skills/anomaly-investigation/SKILL.md +++ b/src/templates/agent-skills/anomaly-investigation/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires cost data (Cost Management or a FinOps hub) at resource granularity and, ideally, Azure Activity Log read access to correlate changes. Pairs with the finops-multitool skill and the finops-toolkit KQL skill. metadata: author: microsoft version: "1.0" @@ -15,7 +15,7 @@ Detection tells you a cost moved; this skill tells you *why* and what to do. It' ## 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. +Use it when the user reports a spike, an unexpected bill, an anomaly alert, or "why did cost jump." Confirm/quantify the anomaly first (anomaly alerts, cost trend, `cost-anomaly-detection.kql`), then drill here. For *setting up* detection/alerts, use `forecasting-budgeting` or the `azure-cost-management` anomaly-alerts skill instead. ## Root-cause drill-down @@ -33,7 +33,7 @@ Narrow the spike one dimension at a time until a single driver remains: |-----------|--------------| | 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`) | +| Spike with no usage change | A reservation/savings plan expired → rate went to on-demand (check ESR, commitment utilization) | | New resource type appears | Net-new deployment, possibly untagged/unowned | | Data-transfer / egress jump | Cross-region traffic, new integration, data exfil pattern — investigate | | Spike then return to baseline | One-off job, batch run, or test left running | @@ -45,7 +45,7 @@ For each confirmed anomaly deliver: **driver** (the specific resource/change), * ## Hand-offs -- Confirm/quantify the anomaly → `finops-multitool` (`scan_anomaly_alerts`, `scan_cost_trend`). +- Confirm/quantify the anomaly → `finops-multitool` (anomaly alerts, cost trend). - Spike caused by expired commitment → `rate-optimization-portfolio`. - Prevent recurrence → `forecasting-budgeting` (alerts) or `azure-policy-governance` (guardrails). - Report it → `finops-reporting`. diff --git a/src/templates/agent-skills/azure-policy-governance/SKILL.md b/src/templates/agent-skills/azure-policy-governance/SKILL.md index aeead1ebe..69175fe93 100644 --- a/src/templates/agent-skills/azure-policy-governance/SKILL.md +++ b/src/templates/agent-skills/azure-policy-governance/SKILL.md @@ -15,12 +15,12 @@ Enforce the guardrails that make FinOps allocation and waste-control durable: ta ## 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. +Use it when the user wants to enforce or audit tagging, restrict what can be deployed, or asks for "a policy" to back up a FinOps recommendation. Run policy inventory and policy recommendations from the `finops-multitool` skill first to see what's already assigned and where the gaps are, then generate policy here. ## Workflow -1. **Inventory** — `scan_policy_inventory` (existing assignments, scopes, effects, compliance). -2. **Gap analysis** — `scan_policy_recommendations` (missing tagging/region/SKU guardrails). +1. **Inventory** — policy inventory (existing assignments, scopes, effects, compliance). +2. **Gap analysis** — policy recommendations (missing tagging/region/SKU guardrails). 3. **Verify definition IDs** — built-in policy IDs change rarely but must be confirmed. Use the Microsoft Learn MCP / docs before emitting any ID into a template. Do not ship an ID from memory. 4. **Generate** — produce Bicep or ARM for the assignment(s), parameterized and scoped. 5. **Stage effects** — deploy as `Audit`/`AuditIfNotExists` first, review compliance, then escalate to `Deny`/`Modify`. Never lead with `Deny` on an existing environment. diff --git a/src/templates/agent-skills/azure-workbooks-finops/SKILL.md b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md index 3043fecec..8095aac17 100644 --- a/src/templates/agent-skills/azure-workbooks-finops/SKILL.md +++ b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires Azure access with Reader (to view) or Workbook Contributor (to save) and, for some tiles, Azure Resource Graph and Cost Management read access. Pairs with the finops-multitool skill. metadata: author: microsoft version: "1.0" diff --git a/src/templates/agent-skills/cost-allocation/SKILL.md b/src/templates/agent-skills/cost-allocation/SKILL.md index 4a4baa7f6..8d82cc6ed 100644 --- a/src/templates/agent-skills/cost-allocation/SKILL.md +++ b/src/templates/agent-skills/cost-allocation/SKILL.md @@ -2,10 +2,10 @@ 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. +compatibility: Requires read access to cost data (Cost Management scope or a FinOps hub) and resource tags. Pairs with the finops-multitool skill (tag and cost-by-tag scans) and the azure-policy-governance skill for enforcement. metadata: author: microsoft - version: "1.0" + version: '1.0' --- # Cost allocation @@ -14,12 +14,12 @@ Allocate Azure cost to the teams, products, and cost centers that own it — the ## 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. +Use it when the user mentions showback, chargeback, allocation, cost centers, "who owns this spend", splitting shared costs, or building a tag strategy for accountability. For raw tag coverage numbers, run the `finops-multitool` scans first (tag inventory, tag recommendations, cost by tag) and bring the results here to design the model. ## Allocation readiness checklist -1. **Coverage** — what % of cost carries the allocation tag(s)? Below ~95% means material spend is unallocated. Use `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`. +1. **Coverage** — what % of cost carries the allocation tag(s)? Below ~95% means material spend is unallocated. Use tag inventory. +2. **Consistency** — no casing or spelling drift in tag keys/values (`CostCenter` vs `costcenter`, `managed_by` vs `managedBy`). Use tag recommendations. 3. **Cost dimension** — the allocation tag must be enabled as a cost-allocation dimension in Cost Management, or tag-dimensioned cost data will be empty even when the tags exist. 4. **Inheritance** — resources that can't be tagged directly (or are missed) should inherit from the resource group via Azure Policy. See the `azure-policy-governance` skill. @@ -27,14 +27,14 @@ Use it when the user mentions showback, chargeback, allocation, cost centers, "w 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 | +| 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 @@ -44,12 +44,12 @@ Pick **one** authoritative allocation key (usually `CostCenter`) and enforce it 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 | +| 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. @@ -64,7 +64,7 @@ Map raw billing + tags into a reporting hierarchy: **Billing account → Billing ## Hand-offs -- Tag coverage / drift numbers → `finops-multitool` MCP tools. +- Tag coverage / drift numbers → `finops-multitool` skill. - Enforce tags and inheritance → `azure-policy-governance` skill. - Visualize allocation → `power-bi-finops` skill (governance / cost-summary reports). - Express allocation efficiency as KPIs → `unit-economics` skill. diff --git a/src/templates/agent-skills/cost-data-source/SKILL.md b/src/templates/agent-skills/cost-data-source/SKILL.md index a06e0a786..9df1004ee 100644 --- a/src/templates/agent-skills/cost-data-source/SKILL.md +++ b/src/templates/agent-skills/cost-data-source/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires the finops-multitool skill and an authenticated Azure session (Connect-AzAccount). The hub Kusto path needs read access to the FinOps Hub Azure Data Explorer / Fabric cluster (or a reachable ftklocal emulator); the storage-reader fallback needs Storage Blob Data Reader on the hub storage account. The cost tools this skill routes are read-only. metadata: author: microsoft version: '1.1' @@ -19,17 +19,17 @@ This skill decides hub vs API so cost scans stay fast and the session stays inte 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) +- cost data (current month actuals per subscription) +- resource costs (top resources by cost) +- cost by tag (spend by tag key/value) -All other cost-family tools — `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. +All other cost-family tools — cost trend, budget status, anomaly alerts, reservation advice, commitment utilization, savings realized — are **not** derivable from cost exports and always run on the live API. Governance and optimization tools are unaffected. ## Protocol: detect first, then decide For any spend question that maps to one of the three tools above, do this **before** calling the cost tool: -1. Call `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. +1. Call the data-source check (pass `subscriptionId` if the user scoped to one subscription). It returns a decision object describing whether a hub was found, whether it is readable, what it covers, how fresh it is, and how long the live API path would take. 2. Branch on the `Recommendation` field. Never silently run a slow API scan — warn and ask first. ### Decision object fields @@ -104,8 +104,8 @@ Always tell the user which source the numbers came from and, for hub data, how f ## Prerequisites -- The `finops-multitool` MCP server must be running (defined in `.vscode/mcp.json`). +- An authenticated Azure session (`az login`, or `Connect-AzAccount` for the terminal UI). - An authenticated Azure session is required (`Connect-AzAccount`). - The scalable hub path needs read access to the FinOps Hub Kusto database — an Azure Data Explorer / Fabric cluster (discovered automatically), or a reachable ftklocal emulator via `FINOPS_HUB_KUSTO_URI`. -- The storage-reader fallback additionally needs **Storage Blob Data Reader** on the hub storage account. Without any hub path, `detect_cost_data_source` reports the blocker and the live API is used instead. +- The storage-reader fallback additionally needs **Storage Blob Data Reader** on the hub storage account. Without any hub path, the data-source check reports the blocker and the live API is used instead. - The cost tools this skill routes are read-only. diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 59f7d3eaa..5c31814db 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -1,163 +1,114 @@ --- 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. +description: This skill should be used when the user asks to "scan for cost savings", "find orphaned resources", "find idle VMs", "check Azure Hybrid Benefit", "review tags", "tag coverage", "tag recommendations", "policy coverage", "cost by tag", "cost trend", "top resources by cost", "reservation recommendations", "commitment utilization", "realized savings", "budget status", "cost anomaly alerts", "Advisor cost recommendations", "billing structure", "contract info", or run a "FinOps assessment", "FinOps scan", or "cost optimization scan". Also use it proactively whenever the conversation turns to Azure cost, waste, savings, governance, or FinOps health and live data would answer the question better than a general explanation. license: MIT -compatibility: Requires 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). +compatibility: Requires an authenticated Azure session (az login, or Connect-AzAccount for PowerShell) with at least Reader access on the target scope. Everything in this skill is read-only. Remediation is deliberately out of scope - use the FinOps multitool terminal UI (Start-FinOpsMultitool, from the FinOpsToolkit PowerShell module) which gates every write behind a preview and confirmation. metadata: author: microsoft - version: '1.0' + version: '2.0' +allowed-tools: az pwsh --- # 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. +This is the routing hub for FinOps investigations. It answers "what should I look at next" rather than "how do I call this API" - it decides which investigation fits the question, tells you how to gather the data, warns you where the raw numbers mislead, and hands off to the skill that turns a finding into a decision. -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. +Two ways to gather the data: -## When to use the server +- **Interactively** - `Start-FinOpsMultitool` launches a terminal UI that runs 30 read-only scan modules, renders the results, and exports them. Best when a person wants the full picture, and the only supported path for remediation. +- **Directly** - query Azure Resource Graph, Cost Management, Advisor, Monitor, or a FinOps hub yourself with `az` or an Azure MCP server. Best when answering one question inside a conversation. This skill carries the queries and the interpretation rules. -Run a scan whenever the user wants real numbers from their environment. Examples that should trigger a tool call: +Prefer the direct path for a single question. Suggest the terminal UI when the user wants a full assessment or intends to act on the findings. -- "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` +## Always confirm scope first -If the user is only asking a conceptual question ("what is Azure Hybrid Benefit?"), answer directly — don't force a scan. +Findings are worthless if they came from the wrong tenant. Before reporting anything: -## Tool routing +```bash +az account show --query "{tenant:tenantId, subscription:name, id:id}" -o json +az account list --query "length(@)" -o tsv +``` -Pick the narrowest tool that answers the question. Use `run_full_scan` only for a broad assessment. +Many accounts can see several tenants and hundreds of subscriptions while queries run against only the active one. State the tenant and subscription you scanned, and confirm it's the intended scope before the user acts on the numbers. -| 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 | +Scope every query explicitly when the user only cares about one subscription. An unscoped tenant-wide scan across hundreds of subscriptions is slow and usually not what was asked for. -## Write / remediation tools +## Investigation routing -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. +| Question | Investigation | Where the detail lives | +| --------------------------------------------- | ------------------------------------ | -------------------------------------------------------------- | +| Where am I wasting money? | Orphaned resources, then idle VMs | [references/waste-detection.md](references/waste-detection.md) | +| Are stopped VMs still costing me? | Orphaned resources | [references/waste-detection.md](references/waste-detection.md) | +| Should I move storage to a cooler tier? | Storage tier analysis | [references/waste-detection.md](references/waste-detection.md) | +| Am I paying for Windows/SQL licenses twice? | Azure Hybrid Benefit eligibility | [references/waste-detection.md](references/waste-detection.md) | +| What's our tag coverage? | Tag inventory, then tag quality | [references/tags-and-policy.md](references/tags-and-policy.md) | +| Are we governed? What guardrails are missing? | Policy inventory, then coverage gaps | [references/tags-and-policy.md](references/tags-and-policy.md) | +| Should we buy reservations or savings plans? | Purchase recommendations | [references/commitments.md](references/commitments.md) | +| Are we using what we already bought? | Commitment utilization | [references/commitments.md](references/commitments.md) | +| What have commitments actually saved us? | Realized savings | [references/commitments.md](references/commitments.md) | +| How is our MACC tracking? | Consumption commitment burn-down | `azure-cost-management` → `references/azure-macc.md` | +| What are we spending? What's the forecast? | Cost summary and trend | [references/cost-analysis.md](references/cost-analysis.md) | +| Which resources cost the most? | Resource cost ranking | [references/cost-analysis.md](references/cost-analysis.md) | +| Split cost by team / app / cost center | Cost by tag | [references/cost-analysis.md](references/cost-analysis.md) | +| Why did cost spike? | Anomaly root cause | `anomaly-investigation` skill | +| Are we on budget? | Budget status and history | `forecasting-budgeting` skill | +| Advisor cost recommendations | Advisor query | `azure-cost-management` → `references/azure-advisor.md` | +| Split shared platform cost across teams | Allocation modelling | `cost-allocation` skill | +| What's our carbon footprint? | Emissions and waste co-benefit | `sustainability-carbon` skill | -| 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 | +When `azure-cost-management` already documents an API, use it rather than duplicating the call here. This skill adds the sequencing and interpretation on top. -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: +## Interpreting common results -- 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. +This is the part raw API output gets wrong. Apply these before reporting a number. -Prefer individual tools for a single question — `run_full_scan` is heavier and returns a large payload. +- **Deallocated VMs are still costing money.** Compute stops billing when a VM is deallocated, but attached managed disks and static public IPs keep billing. A "stopped" VM is a finding, not a resolved item. Report the disk and IP cost, and recommend deleting if the VM is genuinely unused. +- **Advisor repeats reservation recommendations.** Advisor commonly returns the same purchase 3-6 times across overlapping scopes. Summing them inflates the savings estimate several-fold. De-duplicate on subscription + resource type + term + SKU + region + quantity before totalling. See [references/commitments.md](references/commitments.md). +- **Cost by tag returns nothing although resources are tagged.** Two usual causes: the tag isn't enabled as a **cost-allocation dimension** in Cost Management settings, or **month-to-date lag** means tag-dimensioned data hasn't populated. Confirm the tag is applied to resources first, then advise enabling tag-based cost allocation. +- **Empty cost results early in the month.** Cost Management data lags by a day or more. Say so rather than reporting "$0". +- **Azure Hybrid Benefit uses a different marker per resource type.** `Windows_Server` on Windows VMs, `AHUB` on SQL Server VMs, `BasePrice` on SQL Database and Managed Instance. Checking only the first will miss most of the estate and overstate the opportunity. +- **Low tag coverage is usually a naming problem.** Before reporting a coverage percentage, check for casing and separator variants of the same tag (`managed_by` vs `managedBy` vs `ManagedBy`). They fragment coverage and each looks like a distinct tag. ## Reading results -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 percentage. +- Group findings by impact when the data supports it. +- Translate inventory into action: "3 deallocated VMs, disks still billing, delete if unused" beats "3 deallocated VMs". +- Surface the cost driver, not just the resource list. +- Name the scope you scanned every time. -- 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 +## Remediation -- **`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". +This skill does not change Azure. When a finding warrants action, hand the user to `Start-FinOpsMultitool`, which previews every change, requires explicit confirmation, and records an audit trail. Recommend the change and explain the impact; let the tool apply it. ## FinOps skill ecosystem -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. +Once an investigation surfaces a finding, hand off to the skill that turns it into a decision, a design, or an artifact: + +| After finding | Hand off to | For | +| ---------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------- | +| Any spend question against a large environment | `cost-data-source` | Choose the FinOps hub Kusto path over the live API, chunk large tenants | +| Tag coverage or cost-by-tag results | `cost-allocation` | Showback/chargeback model, shared-cost splitting, tag strategy | +| Tag or policy gaps | `azure-policy-governance` | Enforce tags, regions, and SKUs via Azure Policy | +| Policy inventory results | `azure-workbooks-finops` | Live in-portal governance and optimization workbooks | +| Cost trend or resource cost data | `power-bi-finops` | Dashboards and visuals | +| Realized savings or utilization | `unit-economics` | Effective savings rate, cost per unit, coverage KPIs | +| Reservation or Hybrid Benefit opportunities | `rate-optimization-portfolio` | Portfolio mix and purchase planning | +| Budget status | `forecasting-budgeting` | Forecasts, budget design, variance analysis | +| A cost spike | `anomaly-investigation` | Root-cause down to the resource and change | +| Waste findings | `sustainability-carbon` | Carbon co-benefit of removing waste | +| Deep KQL against a FinOps hub | `finops-toolkit` | Kusto analytics on the hub database | +| Single-instrument API mechanics | `azure-cost-management` | Reservations, savings plans, budgets, exports, MACC detail | +| Cost data looks wrong or incomplete | `focus-data-quality` | FOCUS conformance, completeness, mapping | +| A finding the user wants written up | `finops-reporting` | Executive summaries, QBRs, variance narratives | + +Gather the data first so the numbers are real, then route. Don't answer governance, allocation, or reporting questions abstractly when a query can show the actual state. ## Prerequisites -- 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. +- Azure authentication: `az login`, or `Connect-AzAccount` when using the terminal UI. +- **Reader** on the target scope for resource and policy investigations. +- **Cost Management Reader** for cost, budget, and anomaly investigations. +- **Billing Reader**, or Enterprise Administrator (reader) on an Enterprise Agreement, for billing account, contract, and MACC investigations. +- **Carbon Optimization Reader** for emissions data. +- The terminal UI additionally needs the `FinOpsToolkit` PowerShell module and the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules. diff --git a/src/templates/agent-skills/finops-multitool/references/commitments.md b/src/templates/agent-skills/finops-multitool/references/commitments.md new file mode 100644 index 000000000..de2a00c31 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/commitments.md @@ -0,0 +1,90 @@ +# Commitment discounts + +Reservations, savings plans, and the analysis around them: what to buy, whether existing commitments are being used, and what they've actually saved. + +`azure-cost-management` documents the underlying APIs in `references/azure-reservations.md`, `references/azure-savings-plans.md`, and `references/azure-commitment-discount-decision.md`. This page covers the sequencing and the places the raw data misleads. + +## Purchase recommendations + +Two sources, and they disagree by design: + +| Source | Endpoint | Scope | +| ------------------------------ | -------------------------------------------------- | ------------------------------- | +| Azure Advisor | `az advisor recommendation list --category Cost` | Subscription, filtered to cost | +| Reservation Recommendation API | `Microsoft.Consumption/reservationRecommendations` | Billing account or subscription | + +Advisor is easier to reach and is what most agents land on first. It has a serious reporting trap. + +### Advisor emits duplicate recommendations + +Azure Advisor commonly returns **the same purchase 3-6 times**. The records differ only by recommendation GUID, produced by overlapping generation cycles. They are not distinct opportunities. + +Summing `annualSavingsAmount` across raw Advisor records inflates the estimate several-fold. On a large estate this turns a real $40k opportunity into a reported $200k, and nothing in the response signals that anything is wrong. + +De-duplicate on the meaningful tuple before totalling: + +```text +subscriptionId | resourceType | term | SKU | region | quantity | annualSavings +``` + +Keep a count of how many records collapsed into each row. It's useful context, and a high count is itself a signal that Advisor is churning recommendations. + +Report the de-duplicated total. If you present a raw Advisor sum, say explicitly that it may contain duplicates. + +### Interpreting the recommendation + +- **Term.** Advisor usually recommends both 1-year and 3-year. They're alternatives, not additive. Never sum across terms. +- **Lookback.** Recommendations are generated from a 7, 30, or 60-day window. A 7-day lookback after an atypical week produces a bad recommendation. Prefer 30 or 60 day where available. +- **Scope.** Shared-scope reservations apply across subscriptions and generally utilize better than single-subscription ones. A recommendation scoped to one subscription may still be the wrong shape. +- **Quantity.** Advisor recommends the quantity that maximizes savings at its assumed utilization. If the workload is shrinking, buy under the recommendation. + +## Commitment utilization + +The question behind this: _are we wasting what we already bought?_ + +```bash +az rest --method get --url "https://management.azure.com/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" +``` + +Utilization comes from `Microsoft.Consumption/reservationSummaries`, aggregated daily or monthly. + +Thresholds worth flagging: + +| Utilization | Reading | +| ----------- | ------------------------------------------------------------------------ | +| Below 70% | Material waste. Investigate scope and workload changes. | +| 70-90% | Normal for a fluctuating estate. Watch, don't act. | +| Above 95% | Healthy, and possibly under-bought. Check for uncovered on-demand usage. | + +Low utilization has three usual causes, in order of frequency: the reservation is scoped too narrowly, the workload moved region or SKU family, or the workload shrank. Check scope first — it's the cheapest to fix, since scope can be changed without an exchange. + +## Realized savings + +What commitments have already delivered, versus on-demand rates. This is the number FinOps teams report upward, and it's distinct from _projected_ savings in a recommendation. + +Sources: cost data with `pricingModel` or `benefitId` populated, compared against retail rates from the Retail Prices API (`azure-cost-management` → `references/azure-retail-prices.md`). + +Report realized savings and projected savings separately and label them clearly. Blending "we saved $X" with "we could save $Y" is how a savings number loses credibility. + +## MACC + +Microsoft Azure Consumption Commitment burn-down is documented fully in `azure-cost-management` → `references/azure-macc.md`, including the critical detail that `closedBalance` is the **remaining** balance, not the consumed amount. + +Consumed is `originalAmount - closedBalance`. Reporting `closedBalance` as spend inverts the number. + +## Sequencing + +For a commitment review, run in this order: + +1. **Utilization first.** No point recommending purchases while existing commitments sit at 60%. +2. **Coverage second.** How much on-demand usage is uncovered? That's the actual opportunity size. +3. **Recommendations third.** De-duplicated, with term treated as a choice rather than a sum. +4. **Realized savings last.** Establishes credibility for the recommendation that precedes it. + +Leading with purchase recommendations before checking utilization is the most common way a commitment conversation goes wrong. + +## Related + +- `rate-optimization-portfolio` for portfolio mix and purchase planning +- `unit-economics` for effective savings rate and coverage KPIs +- `azure-cost-management` → `references/azure-commitment-discount-decision.md` for reservations vs savings plans diff --git a/src/templates/agent-skills/finops-multitool/references/cost-analysis.md b/src/templates/agent-skills/finops-multitool/references/cost-analysis.md new file mode 100644 index 000000000..05a5666b1 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/cost-analysis.md @@ -0,0 +1,94 @@ +# Cost analysis + +Spend, forecast, trend, and cost broken down by resource or tag — plus the data-source decision that determines whether any of it scales. + +`azure-cost-management` documents the Cost Management APIs themselves. This page covers choosing the data path and reading the results correctly. + +## Choose the data path first + +Three ways to get cost data, in descending order of scale: + +| Path | When | How | +| ------------------- | -------------------------------------- | ----------------------------------------------- | +| FinOps hub Kusto | A hub is deployed and covers the scope | Query the hub's ADX or Fabric database directly | +| Cost Management API | No hub, or a small scope | `az rest` against the query API | +| Hub storage exports | Fallback for small datasets only | Read Parquet/CSV from the hub storage account | + +**Push aggregation into Kusto when a hub exists.** A production hub can hold tens of GB and hundreds of millions of rows. Summarizing in the engine and returning only the result set is the difference between a working answer and a query that never completes. + +Discover a hub cluster: + +```kusto +resources +| where type =~ 'microsoft.kusto/clusters' +| where tags['ftk-tool'] == 'FinOps hubs' +| project name, resourceGroup, subscriptionId, uri = properties.uri +``` + +For local or offline analysis, the `ftklocal` Kusto emulator hosts the same hub database schema. See the `finops-toolkit` skill for the hub query catalog and table shapes. + +## Cost summary and forecast + +```bash +az rest --method post \ + --url "https://management.azure.com/subscriptions//providers/Microsoft.CostManagement/query?api-version=2023-11-01" \ + --body '{ + "type": "ActualCost", + "timeframe": "MonthToDate", + "dataset": { "granularity": "None", "aggregation": { "totalCost": { "name": "Cost", "function": "Sum" } } } + }' +``` + +Forecast uses the same shape against `.../forecast`. + +## Cost by tag + +Add a grouping to the dataset: + +```json +"dataset": { + "granularity": "None", + "aggregation": { "totalCost": { "name": "Cost", "function": "Sum" } }, + "grouping": [ { "type": "TagKey", "name": "" } ] +} +``` + +**When this returns nothing but resources are clearly tagged**, there are two causes and they need different advice: + +1. **The tag isn't enabled as a cost-allocation dimension.** Cost Management only dimensions cost by tags that have been explicitly enabled in settings. This is the usual cause and it's a settings change, not a tagging problem. +2. **Month-to-date lag.** Tag-dimensioned data populates behind raw cost. Early in a month it can be empty even when configured correctly. + +Confirm the tag is applied to resources first (see `tags-and-policy.md`), then check the setting. Reporting "no cost by that tag" without distinguishing these sends people to re-tag an estate that's already tagged. + +## Reading cost results + +- **Billed versus effective cost.** When `BilledCost` is zero — common for commitment-covered usage — fall back to `EffectiveCost`. Treating zero as zero spend undercounts anything covered by a reservation or savings plan. Hub queries and API queries must apply the same rule or the two paths disagree. +- **Amortized versus actual.** Actual cost shows a reservation purchase as a lump on the purchase date. Amortized spreads it across the term. Use amortized for unit economics and trend; use actual for invoice reconciliation. Say which one you used. +- **Currency.** Multi-billing-account estates can mix currencies. Never sum across them without converting, and state the currency in the headline number. +- **Month-to-date is not a month.** Comparing an in-progress month against complete months in a trend produces a fake decline. Either exclude the current month or annotate it. +- **Empty results early in the month.** Cost Management data lags by a day or more. Say so rather than reporting "$0". + +## Resource cost ranking + +Group by `ResourceId` and order descending. Two things to watch: + +- **Resource IDs are not resource names.** Present a readable name and resource group; keep the ID for drill-down. +- **The long tail matters.** Top 10 by cost usually finds the obvious. Concentration — what share the top 10 represent — is often the more useful number. A flat distribution and a top-heavy one call for different strategies. + +## Trend + +Monthly granularity over 6-12 months, grouped as needed. When reporting a change, separate the causes: + +- Rate change — same usage, different price (commitment expired, region moved, SKU changed) +- Usage change — genuinely more or less consumption +- Scope change — subscriptions added or removed from the comparison + +Reporting "cost rose 40%" without identifying which of these is the driver isn't an answer. A subscription added to the scope isn't a cost increase at all. + +## Related + +- `cost-data-source` for the full hub-versus-API decision and chunking large tenants +- `forecasting-budgeting` for forecast method and budget design +- `anomaly-investigation` for root-causing a spike +- `finops-toolkit` for the hub Kusto query catalog +- `azure-cost-management` → `references/azure-cost-exports.md` for scheduled FOCUS exports diff --git a/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md b/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md new file mode 100644 index 000000000..82e541136 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/tags-and-policy.md @@ -0,0 +1,123 @@ +# Tags and policy + +Tag hygiene and Azure Policy coverage — the governance foundation that cost allocation depends on. + +Queries run read-only against Azure Resource Graph via `az graph query`, an Azure MCP Resource Graph tool, or `Search-AzGraph`. + +## Tag inventory + +What tags exist, on how many resources, with what values: + +```kusto +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| extend tagValue = tostring(tags[tagName]) +| where isnotempty(tagName) +| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue +| order by tagName asc, ResourceCount desc +``` + +The `union resourcecontainers` matters — it includes subscriptions and resource groups, which carry the tags that inherited allocation models depend on. Omitting it undercounts coverage badly. + +What carries no tags at all: + +```kusto +resources +| where isnull(tags) or tags == '{}' +| project name, type, resourceGroup, subscriptionId, location +| order by type asc, name asc +``` + +Where a given tag is applied, by scope: + +```kusto +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| where isnotempty(tagName) +| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup +| order by tagName asc, ResourceCount desc +``` + +## Reading tag coverage + +**Check for naming variants before reporting a coverage percentage.** The most common finding in a real estate isn't missing tags — it's the same tag spelled several ways: + +```text +managed_by ManagedBy managedBy Managed-By +costcenter CostCenter cost_center CC +``` + +Each variant appears as a distinct tag in the inventory. Coverage looks like 30% across four tags when it's really 85% of one tag with inconsistent naming. Group case-insensitively and normalize separators before computing coverage, then report the variants as the finding. + +Other things worth flagging: + +- **Tag values that should be an enum but aren't.** Twelve spellings of one team name breaks any groupby. +- **Tags on resources but not on resource groups.** Inherited allocation models silently fail. +- **Reserved prefixes.** Tags beginning `hidden-` or `microsoft` are platform-managed; exclude them from coverage math. + +A tag is only useful for cost allocation if it's also enabled as a **cost-allocation dimension** in Cost Management settings. High tag coverage with no cost-by-tag data almost always means that setting was never turned on. See `cost-analysis.md`. + +## Policy inventory + +Assignments in scope: + +```kusto +policyresources +| where type =~ 'microsoft.authorization/policyassignments' +| project id, name, properties, subscriptionId, type +``` + +Compliance rollup: + +```kusto +policyresources +| where type =~ 'microsoft.policyinsights/policystates' +| extend complianceState = tostring(properties.complianceState) +| summarize + Compliant = countif(complianceState =~ 'Compliant'), + NonCompliant = countif(complianceState =~ 'NonCompliant'), + Total = count() + by subscriptionId +``` + +**A subscription with no policy state records is not compliant — it's unevaluated.** Report those separately. Rolling "no data" into a compliance percentage is the fastest way to produce a governance report that's wrong in the reassuring direction. + +## Policy coverage gaps + +The useful output isn't the list of assignments — it's what's missing. Compare assigned policies against the guardrails a cost-governed environment normally has: + +| Guardrail | Typical built-in | +| ----------------------------------------- | -------------------------------------------------- | +| Require a tag on resources | `Require a tag on resources` | +| Inherit a tag from the resource group | `Inherit a tag from the resource group if missing` | +| Restrict deployment regions | `Allowed locations` | +| Restrict VM SKUs | `Allowed virtual machine size SKUs` | +| Audit resources without a cost center tag | Custom, usually | + +Tag inheritance is the highest-leverage one and the most often missing. `Inherit a tag from the resource group if missing` (definition ID `ea3f2387-9b95-492a-a190-fcdc54f7b070`) is a `modify` effect — it fixes allocation gaps going forward without touching existing resources, and it needs a managed identity on the assignment. + +When recommending policy, distinguish the effects: + +- **Audit** — reports, changes nothing. Safe to assign broadly, and the right first step. +- **Modify** — fixes resources as they're created or remediated. Needs a managed identity. +- **Deny** — blocks deployments. Real breakage risk; assign only after an audit period shows the impact. + +Recommending `deny` on a live subscription without an audit period first is how a governance rollout gets rolled back. + +## Sequencing + +1. Tag inventory first — you can't recommend tag policy without knowing what's there. +2. Normalize variants and report real coverage. +3. Policy inventory, separating unevaluated from non-compliant. +4. Gap analysis against the guardrail list. +5. Recommend `audit` effects first, `modify` for inheritance, `deny` only with evidence. + +## Related + +- `azure-policy-governance` for authoring the policy definitions and assignments as Bicep or ARM +- `cost-allocation` for turning tag coverage into a showback or chargeback model +- `cost-analysis.md` for why tagged resources may still not appear in cost data diff --git a/src/templates/agent-skills/finops-multitool/references/waste-detection.md b/src/templates/agent-skills/finops-multitool/references/waste-detection.md new file mode 100644 index 000000000..33ef1be37 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/waste-detection.md @@ -0,0 +1,208 @@ +# Waste detection + +Finding resources that cost money without delivering value: orphaned infrastructure, idle compute, mis-tiered storage, and unclaimed licensing benefits. + +All queries here are read-only. Run them with `az graph query -q ""`, an Azure MCP Resource Graph tool, or `Search-AzGraph`. + +## Orphaned resources + +Six distinct categories. Run them together and group the results — a single "orphans" number is more actionable than six separate ones. + +### Unattached managed disks + +```kusto +resources +| where type =~ 'microsoft.compute/disks' +| where managedBy == '' or isnull(managedBy) +| where properties.diskState == 'Unattached' +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + sku = sku.name, diskState = properties.diskState +``` + +Premium SSD disks are the expensive case. Report size and SKU, not just the count. + +### Unattached public IPs + +```kusto +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration) +| where properties.natGateway == '' or isnull(properties.natGateway) +| project name, resourceGroup, subscriptionId, location, + sku = sku.name, ipAddress = properties.ipAddress, + allocationMethod = properties.publicIPAllocationMethod +``` + +The `natGateway` check matters. An IP attached to a NAT gateway has no `ipConfiguration` but is very much in use — omitting that filter produces false positives. + +Standard static IPs bill even when idle; Basic dynamic ones largely don't. Lead with the Standard ones. + +### Unattached network interfaces + +```kusto +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where isnull(properties.virtualMachine) or properties.virtualMachine == '' +| where isnull(properties.privateEndpoint) or properties.privateEndpoint == '' +| project name, resourceGroup, subscriptionId, location, + enableAcceleratedNetworking = properties.enableAcceleratedNetworking +``` + +NICs are free. They matter as a signal of abandoned deployments, not as a cost line — say so rather than implying savings. + +### Deallocated VMs + +```kusto +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated' + or properties.extended.instanceView.powerState.code == 'PowerState/deallocated' +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + powerState = properties.extended.instanceView.powerState.displayStatus +``` + +**The one people get wrong.** A deallocated VM stops billing for compute, so it looks resolved. Its managed disks and any static public IP keep billing indefinitely. Treat a long-deallocated VM as an open finding and quantify the disk cost. + +### Empty App Service plans + +```kusto +resources +| where type =~ 'microsoft.web/serverfarms' +| where properties.numberOfSites == 0 +| where sku.tier != 'Free' and sku.tier != 'Shared' +| project name, resourceGroup, subscriptionId, location, + sku = strcat(sku.tier, ' / ', sku.name), + workers = properties.numberOfWorkers +``` + +A plan with no sites bills the full tier. Multiply by `numberOfWorkers` — scaled-out empty plans are expensive. + +### Old snapshots + +```kusto +resources +| where type =~ 'microsoft.compute/snapshots' +| where properties.timeCreated < datetime('') +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + timeCreated = properties.timeCreated +``` + +Substitute a cutoff date — 90 days back is a reasonable default. Confirm retention policy before recommending deletion; snapshots are sometimes the backup. + +## Idle virtual machines + +Two steps. Resource Graph gives the inventory: + +```kusto +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend powerState = tostring(properties.extended.instanceView.powerState.code) +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + osType = properties.storageProfile.osDisk.osType, + powerState +``` + +Then query Monitor metrics per running VM over a 14-day window: + +```bash +az monitor metrics list --resource \ + --metric "Percentage CPU" "Network In Total" "Network Out Total" \ + --start-time <14d-ago> --end-time \ + --aggregation Average Total --interval P14D +``` + +Classification used by the terminal UI: + +| Verdict | Criteria | +| ------- | -------- | +| Idle | average CPU < 5% **and** total network < 14 MB over 14 days | +| Underutilized | average CPU < 10% **and** total network < 140 MB over 14 days | + +**Use both signals.** CPU alone misclassifies a busy file server or a network appliance as idle. A VM moving traffic is doing work regardless of processor load. + +Recommend deallocation for idle VMs and rightsizing for underutilized ones — they're different actions. + +## Storage tier optimization + +Find Hot-tier accounts: + +```kusto +resources +| where type =~ 'microsoft.storage/storageaccounts' +| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier) +| project name, resourceGroup, subscriptionId, location, + kind, sku = sku.name, + accessTier = tostring(properties.accessTier), + creationTime = properties.creationTime +``` + +Then check 30-day transaction volume and blob capacity per account: + +```bash +az monitor metrics list --resource /blobServices/default \ + --metric Transactions --start-time <30d-ago> --end-time \ + --aggregation Total --interval P30D +``` + +Low transactions plus meaningful capacity means the tier is wrong. Recommend Cool for infrequent access, Archive for effectively dormant data. + +**Model the retrieval cost before recommending Archive.** Archive is cheap to store and expensive to read, with rehydration latency measured in hours. If the data is read even occasionally, Cool usually wins on total cost. + +## Azure Hybrid Benefit + +**Three resource types, three different license markers.** Checking only Windows VMs is the common mistake and understates the estate badly. + +### Windows VMs + +```kusto +resources +| where type == 'microsoft.compute/virtualmachines' +| where properties.storageProfile.osDisk.osType =~ 'Windows' +| where isempty(properties.licenseType) or (properties.licenseType !~ 'Windows_Server' and properties.licenseType !~ 'Windows_Client') +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + currentLicense = coalesce(tostring(properties.licenseType), 'None') +``` + +### SQL Server on VMs + +```kusto +resources +| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines' +| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'), + sqlEdition = tostring(properties.sqlImageSku) +``` + +### SQL Database and Managed Instance + +```kusto +resources +| where type == 'microsoft.sql/servers/databases' +| where sku.tier != 'Free' and name != 'master' +| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'), + sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name)) +``` + +Marker summary: + +| Resource type | Property | Value meaning AHB is on | +| ------------- | -------- | ----------------------- | +| Windows VM | `licenseType` | `Windows_Server` (or `Windows_Client`) | +| SQL Server VM | `sqlServerLicenseType` | `AHUB` | +| SQL Database / MI | `licenseType` | `BasePrice` | + +**Eligibility is a licensing question, not a technical one.** These queries find resources that *could* use the benefit. Whether the customer owns qualifying licenses with Software Assurance is something only they can confirm. Present the findings as an opportunity to verify, never as guaranteed savings. + +## Related + +- `azure-cost-management` → `references/azure-orphaned-resources.md` for additional orphan query patterns +- `azure-cost-management` → `references/azure-vm-rightsizing.md` for SKU downsizing analysis +- `sustainability-carbon` for the emissions co-benefit of removing waste diff --git a/src/templates/agent-skills/finops-reporting/SKILL.md b/src/templates/agent-skills/finops-reporting/SKILL.md index 8dabb8398..add7887be 100644 --- a/src/templates/agent-skills/finops-reporting/SKILL.md +++ b/src/templates/agent-skills/finops-reporting/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Works on output from the finops-multitool skill, the finops-toolkit KQL skill, or any cost dataset. Pairs with power-bi-finops for visuals and content-humanizer for tone. metadata: author: microsoft version: "1.0" @@ -14,7 +14,7 @@ Translate cost analytics into the report the audience actually needs. The other ## 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. +Use it when the user asks for a summary, executive report, monthly/quarterly review, QBR, variance explanation, or "write up" of cost or savings. Gather the data first (`finops-multitool` scans, `finops-toolkit` KQL, or a full assessment for a broad sweep), then shape it here. ## Match the report to the audience @@ -28,9 +28,9 @@ Use it when the user asks for a summary, executive report, monthly/quarterly rev ## 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`) +2. **Trend** — are we accelerating, flat, or declining? (cost trend, `monthly-cost-trend.kql`) 3. **Top movers** — the 3 services/resource groups driving the change. -4. **Savings captured** — ESR and realized savings (`scan_savings_realized`, `savings-summary-report.kql`). +4. **Savings captured** — ESR and realized savings (savings realized, `savings-summary-report.kql`). 5. **Opportunities** — top 3 unrealized savings, each with $ impact, effort, and owner. 6. **Anomalies / risks** — anything unusual, expiring commitments, budget overruns. 7. **Recommended actions** — numbered, owned, with a target date. This is the part executives read. @@ -49,7 +49,7 @@ For every finding, give: **what** (the issue), **how much** ($ / month or %), ** ## Hand-offs -- Need the data → `finops-multitool` (`run_full_scan` for breadth) or `finops-toolkit` KQL. +- Need the data → `finops-multitool` (a full assessment for breadth) or `finops-toolkit` KQL. - Need charts in the report → `power-bi-finops`. - Need efficiency KPIs (ESR, unit cost) → `unit-economics`. - Need budget vs actual variance → `forecasting-budgeting`. diff --git a/src/templates/agent-skills/focus-data-quality/SKILL.md b/src/templates/agent-skills/focus-data-quality/SKILL.md index 4e0175aa5..ffef47fc3 100644 --- a/src/templates/agent-skills/focus-data-quality/SKILL.md +++ b/src/templates/agent-skills/focus-data-quality/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires access to the cost dataset — FOCUS exports in storage, or a FinOps hub that ingests them. Pairs with the finops-toolkit skill (hub ingestion) and the finops-multitool skill. metadata: author: microsoft version: "1.0" diff --git a/src/templates/agent-skills/forecasting-budgeting/SKILL.md b/src/templates/agent-skills/forecasting-budgeting/SKILL.md index f8211d5cf..1e3c81f13 100644 --- a/src/templates/agent-skills/forecasting-budgeting/SKILL.md +++ b/src/templates/agent-skills/forecasting-budgeting/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires cost history (Cost Management or a FinOps hub) for forecasting and Cost Management Contributor to create budgets/alerts. Pairs with the finops-toolkit KQL skill and the finops-multitool skill. metadata: author: microsoft version: "1.0" @@ -15,7 +15,7 @@ Look forward, not just back. This skill projects future spend, designs budgets, ## 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. +Use it when the user asks to forecast, budget, plan spend, explain why they're over/under budget, or set up budget alerts. Pull history from cost trend / `monthly-cost-trend.kql` and current budget state from budget status, then plan here. ## Forecasting @@ -51,7 +51,7 @@ Report each driver with its $ contribution so the variance is explained, not jus ## Hand-offs -- History + current budgets → `finops-multitool` (`scan_cost_trend`, `scan_budget_status`) / `finops-toolkit` KQL. +- History + current budgets → `finops-multitool` (cost trend, budget status) / `finops-toolkit` KQL. - Rate-driven variance → `rate-optimization-portfolio` and `unit-economics`. - Sudden unexpected spike → `anomaly-investigation` skill. - Write up the variance → `finops-reporting`. diff --git a/src/templates/agent-skills/power-bi-finops/SKILL.md b/src/templates/agent-skills/power-bi-finops/SKILL.md index daf9eafbe..97188e914 100644 --- a/src/templates/agent-skills/power-bi-finops/SKILL.md +++ b/src/templates/agent-skills/power-bi-finops/SKILL.md @@ -2,10 +2,10 @@ 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. +compatibility: Requires Power BI Desktop (or the Power BI service) and read access to the cost data source — a Cost Management scope, a FinOps hub ADX cluster, or a Fabric workspace. Pairs with the finops-multitool skill and the finops-toolkit skill. metadata: author: microsoft - version: "1.0" + version: '1.0' allowed-tools: az pwsh --- @@ -15,16 +15,16 @@ Build and operate Power BI reporting on Azure cost data. The FinOps toolkit ship ## 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. +Use it when the user wants a visual or dashboard, mentions Power BI, `.pbix`/`.pbit` files, report refresh errors, the Cost Management connector, or asks to "visualize" cost, savings, or tag data. For headless analysis (numbers without visuals) prefer the `finops-multitool` skill or the `finops-toolkit` KQL skill, then bring the output here when the user wants it visualized. ## Choosing a data source -| Data source | Use when | Connector | -|-------------|----------|-----------| -| **FinOps hubs (ADX/Kusto)** | A hub is deployed; want fast, large-scale, multi-month analytics | Azure Data Explorer connector → `Hub` database, `Costs()` / `Prices()` / `Recommendations()` / `Transactions()` | -| **FinOps hubs (Microsoft Fabric RTI)** | Hub deployed to Fabric | Fabric / KQL database connector | -| **Cost Management connector** | No hub; small scope; quick start | Power BI "Microsoft Cost Management" connector (billing account or EA/MCA scope) | -| **FOCUS exports in storage** | Raw FOCUS cost exports only | Azure Data Lake Storage Gen2 connector → parse parquet/csv | +| 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. @@ -32,26 +32,26 @@ The hub data sources are strongly preferred for anything beyond a single small s 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 | +| 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 | +| 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 @@ -63,5 +63,5 @@ Setup steps: open the `.pbit` template → supply the data-source parameters (cl ## 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 live read-only numbers to seed a visual → `finops-multitool` skill (cost trend, resource costs, tag inventory). - Need an executive narrative around the visuals → `finops-reporting` skill. diff --git a/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md b/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md index eb3fe793c..d1d7c2129 100644 --- a/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md +++ b/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires Cost Management read access (or a FinOps hub) for usage, recommendations, and commitment data. Purchase actions need Billing/Reservation permissions. Pairs with the finops-multitool skill and the azure-cost-management skill. metadata: author: microsoft version: "1.0" @@ -29,10 +29,10 @@ Layer them: AHB first (license), then RIs for the stable base, then a savings pl ## Portfolio workflow -1. **Baseline coverage** — what % of commitment-eligible cost is already covered? (`scan_commitment_utilization`, `commitment-discount-utilization.kql`) +1. **Baseline coverage** — what % of commitment-eligible cost is already covered? (commitment utilization, `commitment-discount-utilization.kql`) 2. **Utilization** — are existing commitments fully used? Under-utilization is waste *worse than* on-demand. Fix before buying more. 3. **Gap** — the stable, uncovered base is the buy target. Size to baseline usage, not peak — you can always add, you can't easily unwind. -4. **Instrument choice** — RI for steady single-SKU base; savings plan for flexible compute; AHB for eligible Windows/SQL (`scan_ahb_opportunities`). +4. **Instrument choice** — RI for steady single-SKU base; savings plan for flexible compute; AHB for eligible Windows/SQL (ahb opportunities). 5. **Term** — 1-year for changing estates, 3-year for proven-stable workloads (higher discount, longer lock). 6. **Track expirations** — model the ESR cliff when a commitment lapses; renew or re-shape ahead of expiry. @@ -43,7 +43,7 @@ Layer them: AHB first (license), then RIs for the stable base, then a savings pl | 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`) | +| AHB-eligible VMs at full rate | Leaving license savings on the table | Apply AHB (ahb opportunities) | | Recommendations show large net savings | Genuine gap | Validate against baseline, then commit | ## Guardrails diff --git a/src/templates/agent-skills/sustainability-carbon/SKILL.md b/src/templates/agent-skills/sustainability-carbon/SKILL.md index 4ddfc3cb7..928943928 100644 --- a/src/templates/agent-skills/sustainability-carbon/SKILL.md +++ b/src/templates/agent-skills/sustainability-carbon/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires access to the Microsoft Emissions Impact Dashboard / Azure carbon optimization (Reader on the relevant scope). Pairs with the finops-multitool skill for the cost side of the same resources. metadata: author: microsoft version: '1.0' @@ -20,7 +20,7 @@ Use it when the user mentions carbon, emissions, sustainability, ESG, green/effi | Tool | Provides | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| **`scan_carbon`** (finops-multitool) | kgCO2e totals, month-over-month change, 12-month trend, and per-subscription breakdown. Start here in a multitool-driven session. | +| **carbon** (finops-multitool) | kgCO2e totals, month-over-month change, 12-month trend, and per-subscription breakdown. Start here in a multitool-driven session. | | **Emissions Impact Dashboard (EID)** | Scope 1/2/3 emissions for the Microsoft Cloud footprint, by service/subscription/time | | **Azure carbon optimization** | Per-resource emissions estimates and reduction recommendations in the portal | | **Cloud for Sustainability** | Broader org-level sustainability data model | @@ -33,7 +33,7 @@ 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) | +| Delete orphaned/idle resources (orphaned resources, idle vms) | ↓ spend | ↓ emissions (nothing running) | | Rightsize over-provisioned VMs | ↓ spend | ↓ emissions (less compute) | | Increase utilization / consolidate | ↓ unit cost | ↓ emissions per unit | | Shut down non-prod off-hours | ↓ spend | ↓ emissions | @@ -53,6 +53,6 @@ When the user reports cost optimization, offer the carbon co-benefit: "removing ## Hand-offs -- The wasteful resources (cost side) → `finops-multitool` (`scan_orphaned_resources`, `scan_idle_vms`, `scan_storage_tier_advice`). +- The wasteful resources (cost side) → `finops-multitool` (orphaned resources, idle vms, storage tier advice). - Express carbon-per-unit → `unit-economics` skill. - Put cost + carbon co-benefits in a report → `finops-reporting`. diff --git a/src/templates/agent-skills/unit-economics/SKILL.md b/src/templates/agent-skills/unit-economics/SKILL.md index ad916c536..6e33c2841 100644 --- a/src/templates/agent-skills/unit-economics/SKILL.md +++ b/src/templates/agent-skills/unit-economics/SKILL.md @@ -2,7 +2,7 @@ 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. +compatibility: Requires cost data (Cost Management scope or a FinOps hub) and a business/usage denominator (customers, transactions, requests, GB, etc.). Pairs with the finops-toolkit KQL skill and the finops-multitool skill. metadata: author: microsoft version: "1.0" @@ -22,10 +22,10 @@ Use it when the user asks about cost efficiency, cost per unit, ROI of optimizat |-----|---------|-----------| | **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 | +| **Commitment coverage** | Cost covered by RI/SP ÷ total commitment-eligible cost | commitment utilization, `commitment-discount-utilization.kql` | +| **Commitment utilization** | Used commitment ÷ purchased commitment | commitment utilization | +| **Waste %** | Idle/orphaned cost ÷ total cost | orphaned resources, idle vms | +| **Allocation coverage** | Allocated cost ÷ total cost | cost by tag, `cost-allocation` skill | | **Forecast accuracy** | 1 − |actual − forecast| ÷ actual | `forecasting-budgeting` skill | ## ESR — the headline rate KPI @@ -34,7 +34,7 @@ ESR is the single best measure of rate-optimization maturity. It captures *all* - 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. +- Pair a falling ESR with commitment utilization to find the expiring or under-covered commitment. ## Defining a unit metric diff --git a/src/templates/claude-plugin/skills/anomaly-investigation b/src/templates/claude-plugin/skills/anomaly-investigation new file mode 120000 index 000000000..5f0642882 --- /dev/null +++ b/src/templates/claude-plugin/skills/anomaly-investigation @@ -0,0 +1 @@ +../../agent-skills/anomaly-investigation \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/azure-policy-governance b/src/templates/claude-plugin/skills/azure-policy-governance new file mode 120000 index 000000000..b77a8e757 --- /dev/null +++ b/src/templates/claude-plugin/skills/azure-policy-governance @@ -0,0 +1 @@ +../../agent-skills/azure-policy-governance \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/azure-workbooks-finops b/src/templates/claude-plugin/skills/azure-workbooks-finops new file mode 120000 index 000000000..c1848a6ac --- /dev/null +++ b/src/templates/claude-plugin/skills/azure-workbooks-finops @@ -0,0 +1 @@ +../../agent-skills/azure-workbooks-finops \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/cost-allocation b/src/templates/claude-plugin/skills/cost-allocation new file mode 120000 index 000000000..052255ee2 --- /dev/null +++ b/src/templates/claude-plugin/skills/cost-allocation @@ -0,0 +1 @@ +../../agent-skills/cost-allocation \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/cost-data-source b/src/templates/claude-plugin/skills/cost-data-source new file mode 120000 index 000000000..3f4031e1e --- /dev/null +++ b/src/templates/claude-plugin/skills/cost-data-source @@ -0,0 +1 @@ +../../agent-skills/cost-data-source \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/finops-multitool b/src/templates/claude-plugin/skills/finops-multitool new file mode 120000 index 000000000..b677a1999 --- /dev/null +++ b/src/templates/claude-plugin/skills/finops-multitool @@ -0,0 +1 @@ +../../agent-skills/finops-multitool \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/finops-reporting b/src/templates/claude-plugin/skills/finops-reporting new file mode 120000 index 000000000..638d83735 --- /dev/null +++ b/src/templates/claude-plugin/skills/finops-reporting @@ -0,0 +1 @@ +../../agent-skills/finops-reporting \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/focus-data-quality b/src/templates/claude-plugin/skills/focus-data-quality new file mode 120000 index 000000000..a29785403 --- /dev/null +++ b/src/templates/claude-plugin/skills/focus-data-quality @@ -0,0 +1 @@ +../../agent-skills/focus-data-quality \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/forecasting-budgeting b/src/templates/claude-plugin/skills/forecasting-budgeting new file mode 120000 index 000000000..a7387141f --- /dev/null +++ b/src/templates/claude-plugin/skills/forecasting-budgeting @@ -0,0 +1 @@ +../../agent-skills/forecasting-budgeting \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/power-bi-finops b/src/templates/claude-plugin/skills/power-bi-finops new file mode 120000 index 000000000..117fabb7c --- /dev/null +++ b/src/templates/claude-plugin/skills/power-bi-finops @@ -0,0 +1 @@ +../../agent-skills/power-bi-finops \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/rate-optimization-portfolio b/src/templates/claude-plugin/skills/rate-optimization-portfolio new file mode 120000 index 000000000..74de12a79 --- /dev/null +++ b/src/templates/claude-plugin/skills/rate-optimization-portfolio @@ -0,0 +1 @@ +../../agent-skills/rate-optimization-portfolio \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/sustainability-carbon b/src/templates/claude-plugin/skills/sustainability-carbon new file mode 120000 index 000000000..8d1773376 --- /dev/null +++ b/src/templates/claude-plugin/skills/sustainability-carbon @@ -0,0 +1 @@ +../../agent-skills/sustainability-carbon \ No newline at end of file diff --git a/src/templates/claude-plugin/skills/unit-economics b/src/templates/claude-plugin/skills/unit-economics new file mode 120000 index 000000000..c20191e84 --- /dev/null +++ b/src/templates/claude-plugin/skills/unit-economics @@ -0,0 +1 @@ +../../agent-skills/unit-economics \ No newline at end of file From 19e0d85342e23f222aea280df393da31c56847aa Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 19 Aug 2026 19:52:25 -0600 Subject: [PATCH 72/79] Update FinOps multitool --- docs-mslearn/TOC.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-mslearn/TOC.yml b/docs-mslearn/TOC.yml index 05ae3658b..c5dc49e2c 100644 --- a/docs-mslearn/TOC.yml +++ b/docs-mslearn/TOC.yml @@ -194,7 +194,7 @@ href: toolkit/alerts/finops-alerts-overview.md - name: Configure alerts href: toolkit/alerts/configure-finops-alerts.md - - name: FinOps Multitool + - name: FinOps multitool items: - name: Overview href: toolkit/multitool/finops-multitool-overview.md @@ -244,9 +244,9 @@ href: toolkit/powershell/cost/remove-finopscostexport.md - name: Start-FinOpsCostExport href: toolkit/powershell/cost/start-finopscostexport.md - - name: FinOps Multitool + - name: FinOps multitool items: - - name: FinOps Multitool commands + - name: FinOps multitool commands href: toolkit/powershell/multitool/finops-multitool-commands.md - name: Start-FinOpsMultitool href: toolkit/powershell/multitool/start-finopsmultitool.md From 39eaf0e2ed3257a017eaee74e8ae585d65be9804 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 19 Aug 2026 19:54:32 -0600 Subject: [PATCH 73/79] Update FinOps multitool --- docs-mslearn/toolkit/finops-toolkit-overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index 561036dc1..f69dd8e40 100644 --- a/docs-mslearn/toolkit/finops-toolkit-overview.md +++ b/docs-mslearn/toolkit/finops-toolkit-overview.md @@ -3,7 +3,7 @@ title: FinOps toolkit overview description: Learn how the FinOps toolkit helps you automate and extend the Microsoft Cloud with starter kits, scripts, and advanced solutions to improve FinOps practices. author: flanakin ms.author: micflan -ms.date: 07/02/2026 +ms.date: 08/19/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -33,7 +33,7 @@ The FinOps toolkit is an ever-evolving collection of tools and resources. The fo - [Governance workbook](./workbooks/governance.md) – Central hub for governance. - [Azure Optimization Engine](./optimization-engine/overview.md) – Extensible solution for custom optimization recommendations. - [PowerShell module](./powershell/powershell-commands.md) – Automate and manage FinOps solutions and capabilities. -- [FinOps multitool](./powershell/multitool/finops-multitool-commands.md) – Scan an Azure environment for cost, governance, and optimization insights from a terminal UI or an MCP server for AI agents. +- [FinOps multitool](./powershell/multitool/finops-multitool-commands.md) – Scan an Azure environment for cost, governance, and optimization insights from a terminal UI, with agent skills so AI assistants can run the same analysis. - [Bicep Registry modules](./bicep-registry/modules.md) – Official repository for Bicep modules. - [Open data](open-data.md) – Data available for anyone to access, use, and share without restriction. - [Pricing units](open-data.md#pricing-units) – Microsoft pricing units, distinct units, and scaling factors. From 76910ecad1465d9153c30041327f050ab3613c3b Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 19 Aug 2026 20:02:30 -0600 Subject: [PATCH 74/79] Update FinOps multitool --- .../agent-skills/finops-multitool/SKILL.md | 11 +- .../references/additional-investigations.md | 100 ++++++++++++++ .../finops-multitool/references/allocation.md | 129 ++++++++++++++++++ 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/templates/agent-skills/finops-multitool/references/additional-investigations.md create mode 100644 src/templates/agent-skills/finops-multitool/references/allocation.md diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 5c31814db..1ddedd559 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -50,10 +50,17 @@ Scope every query explicitly when the user only cares about one subscription. An | What are we spending? What's the forecast? | Cost summary and trend | [references/cost-analysis.md](references/cost-analysis.md) | | Which resources cost the most? | Resource cost ranking | [references/cost-analysis.md](references/cost-analysis.md) | | Split cost by team / app / cost center | Cost by tag | [references/cost-analysis.md](references/cost-analysis.md) | +| What does one VM really cost? | VM cost including disks and IP | [references/additional-investigations.md](references/additional-investigations.md) | +| What are we paying per vCPU or per GB? | Unit economics | [references/additional-investigations.md](references/additional-investigations.md) | +| What are we spending on Azure OpenAI? | AI workload cost per 1K tokens | [references/additional-investigations.md](references/additional-investigations.md) | +| Are we running retiring SKUs? | Legacy resources | [references/additional-investigations.md](references/additional-investigations.md) | +| Was the budget ever realistic? | Budget history | [references/additional-investigations.md](references/additional-investigations.md) | +| What does the tenant hierarchy look like? | Management group structure | [references/additional-investigations.md](references/additional-investigations.md) | +| Split shared hub or platform cost | Shared and telemetry-keyed splitting | [references/allocation.md](references/allocation.md) | | Why did cost spike? | Anomaly root cause | `anomaly-investigation` skill | -| Are we on budget? | Budget status and history | `forecasting-budgeting` skill | +| Are we on budget? | Budget status | `forecasting-budgeting` skill | | Advisor cost recommendations | Advisor query | `azure-cost-management` → `references/azure-advisor.md` | -| Split shared platform cost across teams | Allocation modelling | `cost-allocation` skill | +| Design a showback or chargeback model | Allocation modelling | `cost-allocation` skill | | What's our carbon footprint? | Emissions and waste co-benefit | `sustainability-carbon` skill | When `azure-cost-management` already documents an API, use it rather than duplicating the call here. This skill adds the sequencing and interpretation on top. diff --git a/src/templates/agent-skills/finops-multitool/references/additional-investigations.md b/src/templates/agent-skills/finops-multitool/references/additional-investigations.md new file mode 100644 index 000000000..9e33389b0 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/additional-investigations.md @@ -0,0 +1,100 @@ +# Unit economics and remaining investigations + +Cost per unit of capacity, plus the investigations not covered by the other reference pages. + +## Unit economics + +Divide amortized cost by provisioned capacity. Four ratios, each answering a different question. + +| Metric | Formula | Answers | +| ------ | ------- | ------- | +| Cost per vCPU | compute cost ÷ total provisioned vCPUs | Are we paying a fair rate for compute? | +| Cost per GB RAM | compute cost ÷ total provisioned memory GB | Is the workload memory-skewed? | +| Cost per VM | compute cost ÷ VM count | Rough fleet average, useful for trend | +| Cost per GB stored | storage cost ÷ total GB | Is storage tiered correctly? | + +### Getting the numbers right + +**Use amortized cost, not actual.** A reservation purchase lands as a lump on the purchase date in actual cost. Divide that by vCPUs and the month of purchase shows an absurd rate. Amortized spreads it across the term, which is what a unit rate should reflect. + +**vCPU and memory are not in Resource Graph.** The `resources` table exposes `vmSize` but not its capabilities. Resolve size → vCPU/memory through the Compute SKUs API per region: + +```bash +az vm list-skus --location --resource-type virtualMachines \ + --query "[].{name:name, caps:capabilities}" -o json +``` + +Cache it per region — the response is large and identical for every VM in that region. Falling back to parsing the size name (`Standard_D4s_v5` → 4 vCPU) works often enough to be dangerous: it silently misreads constrained-vCPU sizes and several families. Prefer the API and note when you had to guess. + +**Storage GB has two sources.** Provisioned managed-disk size from Resource Graph, plus *used* capacity for storage accounts from the `UsedCapacity` metric — one call per account. Disks are provisioned; blob storage is consumed. Mixing provisioned and used without saying so produces a rate nobody can reconcile. + +### Reading the result + +A unit rate is only meaningful as a trend or a comparison. "$47 per vCPU per month" alone means nothing. Rising month over month with flat capacity means rate erosion — usually an expiring commitment. Falling with flat cost means capacity was added without cost, which usually means something is provisioned and idle. + +Pair a rising cost-per-vCPU with commitment utilization before concluding anything. See `commitments.md`. + +## Legacy resources + +Retiring SKUs, deprecated API versions, and resources on classic deployment models. These carry migration risk and often a price premium. + +```kusto +resources +| where sku.name in ('Basic', 'Standard_LRS') + or type startswith 'microsoft.classic' +| project name, type, resourceGroup, subscriptionId, sku = sku.name, location +``` + +Adjust the SKU list to what you're hunting. The high-value cases are Basic-tier public IPs and load balancers (both retiring), classic storage and compute, and unmanaged disks. + +Report the retirement date alongside the finding — "deprecated" without a date doesn't drive action. + +## Budget history + +Budget status shows the current period. History shows whether the budget was ever realistic. + +Pull budgets from `Microsoft.Consumption/budgets`, then query cost per month over the same window and compare. A budget exceeded every month for six months isn't an alerting problem, it's a budget that was set wrong — recommend re-baselining rather than more alerts. + +See `forecasting-budgeting` for budget design. + +## AI workload spend + +Azure OpenAI and Cognitive Services cost, joined to token telemetry. + +Cost comes from Cost Management filtered to `MICROSOFT.COGNITIVESERVICES`. Token volume comes from Azure Monitor: + +```kusto +AzureMetrics +| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES' +| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens') +| summarize tokens = sum(Total) by Resource, MetricName +``` + +The useful output is **cost per 1K tokens**, per deployment. That's the number that tells you whether a model choice is defensible — and it exposes the case where a small amount of traffic on an expensive model dominates spend. + +Prompt and generated tokens price differently. Keep them separate. + +## VM cost breakdown + +Full cost for one VM: compute, plus attached disks, plus the public IP, plus bandwidth. + +Cost Management grouped by `ResourceId` gives the compute line. The attached resources bill separately and are easy to miss — which is exactly why a deallocated VM still costs money. + +Resolve the VM's disks and NICs from Resource Graph, then sum their costs alongside. Reporting the compute line alone understates a VM's real cost, often substantially for storage-heavy machines. + +## Tenant hierarchy + +Management group and subscription structure, used to understand scope before a broad scan. + +```bash +az account management-group list --query "[].{name:name, displayName:displayName}" -o table +``` + +Mostly a prerequisite rather than a finding. Useful when cost is being reported at the wrong scope, or when a subscription sits outside the expected hierarchy and is escaping policy. + +## Related + +- `unit-economics` skill for KPI definitions and business denominators +- `commitments.md` for the commitment side of a rising unit rate +- `cost-analysis.md` for amortized versus actual cost +- `forecasting-budgeting` for budget design and variance diff --git a/src/templates/agent-skills/finops-multitool/references/allocation.md b/src/templates/agent-skills/finops-multitool/references/allocation.md new file mode 100644 index 000000000..266623f89 --- /dev/null +++ b/src/templates/agent-skills/finops-multitool/references/allocation.md @@ -0,0 +1,129 @@ +# Cost allocation + +Splitting shared cost across the teams that consume it. Two distinct problems with different solutions. + +`cost-allocation` covers the showback/chargeback model design. This page covers the arithmetic. + +## Which problem are you solving? + +| Consumer is... | Native Azure allocation? | Method | +| -------------- | ------------------------ | ------ | +| A subscription, resource group, or tag | **Yes** — cost allocation rules | Write a rule; cost moves in Cost Management | +| A hub resource shared by spoke subscriptions | Partly | Split by a measured key, then optionally write a rule | +| A Kubernetes namespace, APIM product, or OpenAI deployment | **No** | Telemetry-keyed showback only | + +That last row is the trap. Azure cost allocation rules can only key on **SubscriptionId, ResourceGroupName, or Tag**. A namespace is none of those. Any split you produce for it is a reporting artifact — it can never be written back into Cost Management, and presenting it as though it can is a promise you can't keep. + +## Shared hub cost + +Shared connectivity — ExpressRoute gateway and circuit, VPN gateway, Azure Firewall, shared bandwidth — bills entirely to the hub subscription. Cost Management cannot tell which spoke generated which gigabyte, because the split key lives in network telemetry rather than billing data. + +### The split model + +```text +spoke_i = (Fixed / nSpokes) + (Variable × weight_i / totalWeight) + +Fixed = pool × FixedRatio split evenly +Variable = pool × (1 − FixedRatio) split by measured weight +``` + +The fixed component exists because a gateway costs money at zero traffic. Splitting the whole pool by transfer volume charges a spoke that sent nothing exactly nothing, which is wrong — it still had the circuit available. + +A `FixedRatio` of 0.3 to 0.5 is a reasonable starting point for connectivity. State the ratio you used; it's a policy decision, not a fact. + +### Weighting providers, best to worst + +| Provider | Accuracy | Source | +| -------- | -------- | ------ | +| `inline` | Exact | Caller supplies a measured GB/TB map | +| `trafficAnalytics` | Exact | Traffic Analytics / VNet flow logs in Log Analytics | +| `resourceCount` | Proxy | Billable resource count per spoke, from Resource Graph | +| `equal` | None | Even split | + +**Per-spoke ExpressRoute attribution is impossible from billing data alone.** It requires the flow-log key. Without flow logs you are estimating — label the output that way rather than presenting a proxy split as measured. + +### Reporting it + +Each spoke's full solution cost is its own resources **plus** its allocated share. Reporting only the allocated share understates what the team actually consumes, and reporting only their own resources hides the shared cost entirely — which is the problem you started with. + +## Telemetry-keyed showback + +For consumers with no billing dimension, pull a usage signal from Log Analytics and apportion the pool by it. + +### AKS namespace + +Source: Container Insights. + +```kusto +InsightsMetrics +| where TimeGenerated >= ago(30d) +| where Name in ('cpuUsageNanoCores', 'memoryWorkingSetBytes') +| extend ns = tostring(parse_json(Tags)['container.azm.ms/namespace']) +| where isnotempty(ns) +| summarize cpuCores = avgif(Val, Name == 'cpuUsageNanoCores') / 1000000000.0, + memGB = avgif(Val, Name == 'memoryWorkingSetBytes') / 1073741824.0 + by ns +| extend Weight = cpuCores + (memGB / 4.0) +| project Consumer = ns, Weight +``` + +The `cpuCores + (memGB / 4)` blend approximates how AKS node SKUs are actually priced — roughly 4 GB of memory per vCPU. Weighting on CPU alone overcharges compute-light, memory-heavy workloads. + +### APIM by product or subscription + +Source: workspace-based Application Insights. + +```kusto +AppMetrics +| where TimeGenerated >= ago(30d) +| where Name in ('Total Tokens', 'Tokens', 'TotalTokens', 'total_tokens') +| extend consumer = tostring(Properties['Subscription Id']) +| where isnotempty(consumer) +| summarize Weight = sum(Sum) by Consumer = consumer +``` + +Swap the `Properties[...]` key for `API ID` or `Product` depending on which consumer you're charging. The metric name varies by APIM version, hence the `in (...)` list. + +### Azure OpenAI by deployment + +Source: Azure Monitor metrics. + +```kusto +AzureMetrics +| where TimeGenerated >= ago(30d) +| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES' +| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'ProcessedInferenceTokens') +| summarize Weight = sum(Total) by Consumer = Resource +``` + +**Prompt and generated tokens are not priced the same.** Summing them into one weight is a simplification. If the split drives real chargeback, weight them separately at their respective rates. + +### Any custom consumer + +The pattern generalizes: a query returning two columns, `Consumer` (string) and `Weight` (number). Everything downstream is the same apportionment. + +## Applying the split + +Once weights exist: + +```text +consumer_i = pool × (weight_i / totalWeight) +``` + +Then decide what happens to it: + +- **Subscription, resource group, or tag consumers** — writable as a native cost allocation rule. Percentages are normalized to sum to 100. +- **Everything else** — showback only. Report it; don't imply Cost Management will reflect it. + +## Sanity checks before reporting + +- **Do the parts sum to the pool?** Rounding across many consumers drifts. Reconcile and put the remainder somewhere explicit. +- **Is the telemetry window the same as the cost window?** 30 days of usage against a calendar month of cost is a mismatch that nobody notices until the numbers are questioned. +- **Did every consumer emit telemetry?** A namespace with no metrics gets zero weight and therefore zero cost, which is almost never true. Flag missing consumers rather than silently charging them nothing. +- **Is the pool the right pool?** Resolve the shared resources explicitly. Sweeping in a resource group can pick up non-shared resources and inflate every share. + +## Related + +- `cost-allocation` skill for the showback/chargeback model and tag strategy +- `tags-and-policy.md` for the tag coverage that native allocation depends on +- `azure-cost-management` → `references/azure-cost-exports.md` for the underlying cost data From 5f5deaa8abd01c20bb297dc337a22a66891eaf8f Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 19 Aug 2026 21:22:56 -0600 Subject: [PATCH 75/79] Update FinOps multitool --- .../modules/Remove-OrphanedResource.ps1 | 18 +++++++- .../modules/helpers/Get-FOHubProvider.ps1 | 4 +- .../agent-skills/finops-multitool/SKILL.md | 42 +++++++++---------- .../finops-multitool/references/allocation.md | 22 +++++----- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 index d3910bf5e..53220bd13 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 @@ -114,8 +114,22 @@ function Remove-OrphanedResource { $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 } + + # Without a parsed body the orphan check below cannot evaluate anything and + # would pass by default, so refuse rather than delete on unverified state. + if (-not $resObj) { + return [PSCustomObject]@{ + HasData = $false + Mode = 'Blocked' + Applied = $false + Error = "Refusing to delete: the response for '$resName' could not be parsed, so it cannot be confirmed as orphaned." + ResourceId = $ResourceId + ResourceType = $fullType + } + } + + $props = $resObj.properties + $location = $resObj.location # ---- Defense in depth: verify the resource is genuinely orphaned ---- $inUseBy = @() diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 75ecc334e..b2a0b9143 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -286,7 +286,9 @@ $scope # 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 ', ' + # Escape backslash before quote, matching ConvertTo-KqlLiteral, so a tag key + # ending in a backslash cannot terminate the KQL string early. + $keyList = ($keys | Where-Object { $_ } | ForEach-Object { '"' + $_.Replace('\', '\\').Replace('"', '\"') + '"' }) -join ', ' $query = @" $(Get-FOHubAnchorLet) let src = Costs diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 1ddedd559..1cf346717 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -35,33 +35,33 @@ Scope every query explicitly when the user only cares about one subscription. An ## Investigation routing -| Question | Investigation | Where the detail lives | -| --------------------------------------------- | ------------------------------------ | -------------------------------------------------------------- | -| Where am I wasting money? | Orphaned resources, then idle VMs | [references/waste-detection.md](references/waste-detection.md) | -| Are stopped VMs still costing me? | Orphaned resources | [references/waste-detection.md](references/waste-detection.md) | -| Should I move storage to a cooler tier? | Storage tier analysis | [references/waste-detection.md](references/waste-detection.md) | -| Am I paying for Windows/SQL licenses twice? | Azure Hybrid Benefit eligibility | [references/waste-detection.md](references/waste-detection.md) | -| What's our tag coverage? | Tag inventory, then tag quality | [references/tags-and-policy.md](references/tags-and-policy.md) | -| Are we governed? What guardrails are missing? | Policy inventory, then coverage gaps | [references/tags-and-policy.md](references/tags-and-policy.md) | -| Should we buy reservations or savings plans? | Purchase recommendations | [references/commitments.md](references/commitments.md) | -| Are we using what we already bought? | Commitment utilization | [references/commitments.md](references/commitments.md) | -| What have commitments actually saved us? | Realized savings | [references/commitments.md](references/commitments.md) | -| How is our MACC tracking? | Consumption commitment burn-down | `azure-cost-management` → `references/azure-macc.md` | -| What are we spending? What's the forecast? | Cost summary and trend | [references/cost-analysis.md](references/cost-analysis.md) | -| Which resources cost the most? | Resource cost ranking | [references/cost-analysis.md](references/cost-analysis.md) | -| Split cost by team / app / cost center | Cost by tag | [references/cost-analysis.md](references/cost-analysis.md) | +| Question | Investigation | Where the detail lives | +| --------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------- | +| Where am I wasting money? | Orphaned resources, then idle VMs | [references/waste-detection.md](references/waste-detection.md) | +| Are stopped VMs still costing me? | Orphaned resources | [references/waste-detection.md](references/waste-detection.md) | +| Should I move storage to a cooler tier? | Storage tier analysis | [references/waste-detection.md](references/waste-detection.md) | +| Am I paying for Windows/SQL licenses twice? | Azure Hybrid Benefit eligibility | [references/waste-detection.md](references/waste-detection.md) | +| What's our tag coverage? | Tag inventory, then tag quality | [references/tags-and-policy.md](references/tags-and-policy.md) | +| Are we governed? What guardrails are missing? | Policy inventory, then coverage gaps | [references/tags-and-policy.md](references/tags-and-policy.md) | +| Should we buy reservations or savings plans? | Purchase recommendations | [references/commitments.md](references/commitments.md) | +| Are we using what we already bought? | Commitment utilization | [references/commitments.md](references/commitments.md) | +| What have commitments actually saved us? | Realized savings | [references/commitments.md](references/commitments.md) | +| How is our MACC tracking? | Consumption commitment burn-down | `azure-cost-management` → `references/azure-macc.md` | +| What are we spending? What's the forecast? | Cost summary and trend | [references/cost-analysis.md](references/cost-analysis.md) | +| Which resources cost the most? | Resource cost ranking | [references/cost-analysis.md](references/cost-analysis.md) | +| Split cost by team / app / cost center | Cost by tag | [references/cost-analysis.md](references/cost-analysis.md) | | What does one VM really cost? | VM cost including disks and IP | [references/additional-investigations.md](references/additional-investigations.md) | | What are we paying per vCPU or per GB? | Unit economics | [references/additional-investigations.md](references/additional-investigations.md) | | What are we spending on Azure OpenAI? | AI workload cost per 1K tokens | [references/additional-investigations.md](references/additional-investigations.md) | | Are we running retiring SKUs? | Legacy resources | [references/additional-investigations.md](references/additional-investigations.md) | | Was the budget ever realistic? | Budget history | [references/additional-investigations.md](references/additional-investigations.md) | | What does the tenant hierarchy look like? | Management group structure | [references/additional-investigations.md](references/additional-investigations.md) | -| Split shared hub or platform cost | Shared and telemetry-keyed splitting | [references/allocation.md](references/allocation.md) | -| Why did cost spike? | Anomaly root cause | `anomaly-investigation` skill | -| Are we on budget? | Budget status | `forecasting-budgeting` skill | -| Advisor cost recommendations | Advisor query | `azure-cost-management` → `references/azure-advisor.md` | -| Design a showback or chargeback model | Allocation modelling | `cost-allocation` skill | -| What's our carbon footprint? | Emissions and waste co-benefit | `sustainability-carbon` skill | +| Split shared hub or platform cost | Shared and telemetry-keyed splitting | [references/allocation.md](references/allocation.md) | +| Why did cost spike? | Anomaly root cause | `anomaly-investigation` skill | +| Are we on budget? | Budget status | `forecasting-budgeting` skill | +| Advisor cost recommendations | Advisor query | `azure-cost-management` → `references/azure-advisor.md` | +| Design a showback or chargeback model | Allocation modelling | `cost-allocation` skill | +| What's our carbon footprint? | Emissions and waste co-benefit | `sustainability-carbon` skill | When `azure-cost-management` already documents an API, use it rather than duplicating the call here. This skill adds the sequencing and interpretation on top. diff --git a/src/templates/agent-skills/finops-multitool/references/allocation.md b/src/templates/agent-skills/finops-multitool/references/allocation.md index 266623f89..9d31d1bc4 100644 --- a/src/templates/agent-skills/finops-multitool/references/allocation.md +++ b/src/templates/agent-skills/finops-multitool/references/allocation.md @@ -6,11 +6,11 @@ Splitting shared cost across the teams that consume it. Two distinct problems wi ## Which problem are you solving? -| Consumer is... | Native Azure allocation? | Method | -| -------------- | ------------------------ | ------ | -| A subscription, resource group, or tag | **Yes** — cost allocation rules | Write a rule; cost moves in Cost Management | -| A hub resource shared by spoke subscriptions | Partly | Split by a measured key, then optionally write a rule | -| A Kubernetes namespace, APIM product, or OpenAI deployment | **No** | Telemetry-keyed showback only | +| Consumer is... | Native Azure allocation? | Method | +| ---------------------------------------------------------- | ------------------------------- | ----------------------------------------------------- | +| A subscription, resource group, or tag | **Yes** — cost allocation rules | Write a rule; cost moves in Cost Management | +| A hub resource shared by spoke subscriptions | Partly | Split by a measured key, then optionally write a rule | +| A Kubernetes namespace, APIM product, or OpenAI deployment | **No** | Telemetry-keyed showback only | That last row is the trap. Azure cost allocation rules can only key on **SubscriptionId, ResourceGroupName, or Tag**. A namespace is none of those. Any split you produce for it is a reporting artifact — it can never be written back into Cost Management, and presenting it as though it can is a promise you can't keep. @@ -33,12 +33,12 @@ A `FixedRatio` of 0.3 to 0.5 is a reasonable starting point for connectivity. St ### Weighting providers, best to worst -| Provider | Accuracy | Source | -| -------- | -------- | ------ | -| `inline` | Exact | Caller supplies a measured GB/TB map | -| `trafficAnalytics` | Exact | Traffic Analytics / VNet flow logs in Log Analytics | -| `resourceCount` | Proxy | Billable resource count per spoke, from Resource Graph | -| `equal` | None | Even split | +| Provider | Accuracy | Source | +| ------------------ | -------- | ------------------------------------------------------ | +| `inline` | Exact | Caller supplies a measured GB/TB map | +| `trafficAnalytics` | Exact | Traffic Analytics / VNet flow logs in Log Analytics | +| `resourceCount` | Proxy | Billable resource count per spoke, from Resource Graph | +| `equal` | None | Even split | **Per-spoke ExpressRoute attribution is impossible from billing data alone.** It requires the flow-log key. Without flow logs you are estimating — label the output that way rather than presenting a proxy split as measured. From a8da2f44949e2c026815c4a5a2b2ca5897d1f4b0 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 12:56:06 -0600 Subject: [PATCH 76/79] fix(multitool): address review findings 1, 2, 3, 4, 6, 9 Finding 6 (High): Get-AzAccessToken now returns Token as a SecureString, so interpolating it produced "Bearer System.Security.SecureString" and every metric query returned 401. The empty catch turned that into zero findings rather than an error. Get-IdleVMs, Get-StorageTierAdvice, and Get-AIWorkloadMetrics now use Get-PlainAccessToken, and the AI scanner surfaces a token failure instead of swallowing it. Finding 3 (High): Get-PlainAccessToken used PtrToStringAuto, which picks the platform default encoding and truncated the JWT to one character on macOS. A BSTR is always UTF-16, so decode with PtrToStringBSTR. Finding 1 (High): Microsoft.Compute/snapshots declared inUseProps = @(), so the orphan verification loop iterated zero times and passed vacuously. Whether a snapshot is safe to delete depends on retention and backup policy, which this tool does not evaluate, and the delete is irreversible. Removed snapshots from the deletable allow-list. Finding 2 (High): the selected subscription scope was computed but never applied. Passed -SubscriptionIds to the three Hub Kusto queries, added -RestrictToSelected in the generic scan dispatcher for the scanners that declare it, and added -SubscriptionIds plus a shared row-subscription resolver to the Hub storage reader, which had no scope mechanism at all. Finding 4 (High): provider errors were discarded and the UI printed "Hub data summarized in-engine" unconditionally, then fell back to the Cost Management API while still labelling results as Hub. Errors are now surfaced per query and a total failure states that results are not from the Hub. Finding 9 (Medium): Initialize-Tests.ps1 already declares a root-level BeforeAll, and Pester 6 rejects a second one during discovery, so the Hub provider tests would not run in CI (CI installs Pester unpinned). Moved the module import into the Describe block. Also replaced MCP tool names and apply=true syntax in user-facing messages with the PowerShell equivalents, since the MCP server was removed. --- .../Invoke-FinOpsMultitool.ps1 | 46 +++++++++++++++---- .../modules/Enable-HybridBenefit.ps1 | 2 +- .../modules/Get-AIWorkloadMetrics.ps1 | 6 ++- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 4 +- .../modules/Get-StorageTierAdvice.ps1 | 2 +- .../modules/Remove-OrphanedResource.ps1 | 28 +++++------ .../FinOpsMultitool/modules/Stop-IdleVm.ps1 | 2 +- .../modules/helpers/Get-PlainAccessToken.ps1 | 4 +- .../modules/helpers/Read-FinOpsHubData.ps1 | 34 +++++++++++++- .../Tests/Unit/FOHubProvider.Tests.ps1 | 18 ++++---- 10 files changed, 105 insertions(+), 41 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 5e58f8780..93991ef0e 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -609,8 +609,8 @@ function Invoke-FinOpsMultitool { # what lets the tool scale to large hub datasets. Falls back to the # storage reader below when no cluster is available. $kustoProvider = $null + $subIdsForDisco = @($Subscriptions | ForEach-Object { $_.Id }) 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 } } @@ -618,13 +618,36 @@ function Invoke-FinOpsMultitool { 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 + + # Scope every query to the selected subscriptions. Without this the + # hub returns every subscription it holds, contaminating a report + # the user asked to be scoped to one. + $hubErrors = [System.Collections.Generic.List[string]]::new() + $hubOk = 0 + + $cs = Get-FOHubCostSummary -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($cs -is [System.Collections.IDictionary] -and $cs.Contains('Error') -and $cs.Error) { $hubErrors.Add("cost summary: $($cs.Error)") } + else { $hubCostData = $cs; $hubOk++ } + + $rc = Get-FOHubResourceCosts -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error) { $hubErrors.Add("resource costs: $($rc.Error)") } + else { $hubResourceCosts = $rc; $hubOk++ } + + $ct = Get-FOHubCostByTag -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error) { $hubErrors.Add("cost by tag: $($ct.Error)") } + else { $hubCostByTag = $ct; $hubOk++ } + + if ($hubOk -gt 0) { + Write-Host " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray + } + foreach ($e in $hubErrors) { + Write-Host " Hub query failed - $e" -ForegroundColor Yellow + } + if ($hubOk -eq 0) { + # Nothing came back from the hub, so the numbers below are Cost + # Management API results. Say so rather than labelling them Hub. + Write-Host " No Hub results. Falling back to the Cost Management API - results are NOT from the FinOps Hub." -ForegroundColor Yellow + } } elseif ($DataSource.HubStorage) { # Storage reader: small-dataset convenience path (rows loaded into @@ -639,7 +662,7 @@ function Invoke-FinOpsMultitool { Write-Host " Loading Hub tag data for fast tag scans..." -ForegroundColor DarkGray } try { - $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1 + $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1 -SubscriptionIds $subIdsForDisco } catch { Write-Host " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow @@ -924,6 +947,11 @@ function Invoke-FinOpsMultitool { if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('TenantId') -and $TenantId) { $params['TenantId'] = $TenantId } + # The user picked a subscription set, so management-group + # scope queries must be filtered back down to it. + if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('RestrictToSelected')) { + $params['RestrictToSelected'] = $true + } $output = & $fn @params } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 index fc7f70e31..8e818e662 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 @@ -146,7 +146,7 @@ function Enable-HybridBenefit { 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.' } + else { 'Re-run Enable-HybridBenefit with -Apply to enable AHB.' } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index ed1deb7cd..055f347d0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -168,7 +168,8 @@ resources if (-not $fromHub -and $openAiAccounts.Count -gt 0) { $token = $null $armBase = Get-FinOpsArmEndpoint - try { $token = (Get-AzAccessToken -ResourceUrl $armBase).Token } catch { } + $tokenError = $null + try { $token = Get-PlainAccessToken -ResourceUrl $armBase } catch { $tokenError = $_.Exception.Message } if ($token) { $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } @@ -231,6 +232,9 @@ resources } } } + elseif ($tokenError) { + Write-Warning "AI workload metrics skipped: could not acquire an access token. $tokenError" + } } # When TokenTransaction is sparse, fall back to prompt + generated. diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index fec7c73fa..29988986b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -52,7 +52,7 @@ resources 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." + "$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 - run the orphaned resources scan." } else { 'No virtual machines found in scope.' @@ -70,7 +70,7 @@ resources # -- 2: Query 14-day avg CPU + Network for each VM ------------------- $armBase = Get-FinOpsArmEndpoint - $token = (Get-AzAccessToken -ResourceUrl $armBase).Token + $token = Get-PlainAccessToken -ResourceUrl $armBase $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } $now = (Get-Date).ToUniversalTime() $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index 095ff40b1..5826bde7f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -45,7 +45,7 @@ resources # -- 2: For each hot account, check last access metrics --------------- $armBase = Get-FinOpsArmEndpoint - $token = (Get-AzAccessToken -ResourceUrl $armBase).Token + $token = Get-PlainAccessToken -ResourceUrl $armBase $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } $now = (Get-Date).ToUniversalTime() $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 index 53220bd13..bbfbf4b28 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 @@ -6,7 +6,7 @@ # 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) +# disk, dangling public IP, or unattached NIC) # discovered by scan_orphaned_resources. # # Description: @@ -47,12 +47,17 @@ function Remove-OrphanedResource { # 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). + # Every entry must have at least one such property - a type with none + # would pass the orphan check vacuously. + # + # Snapshots are deliberately excluded. A snapshot has no in-use property; + # whether it is safe to delete depends on retention and backup policy, + # which this tool does not evaluate, and the delete is irreversible. # ----------------------------------------------------------------- $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 ---- @@ -66,7 +71,7 @@ function Remove-OrphanedResource { 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)." + Error = "Could not parse a top-level resource type from resourceId. This tool only deletes top-level orphaned resources (disks, public IPs, NICs)." } } $fullType = "$($Matches.ns)/$($Matches.type)" @@ -156,7 +161,7 @@ function Remove-OrphanedResource { 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." + Error = "Refusing to delete: '$resName' appears to be IN USE ($($inUseBy -join ', ')). It is not orphaned. Re-run the orphaned resources scan to refresh, or detach it first." ResourceId = $ResourceId ResourceType = $fullType Location = $location @@ -180,18 +185,9 @@ function Remove-OrphanedResource { '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.' - } + $irreversible = '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 } @@ -241,10 +237,10 @@ function Remove-OrphanedResource { 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.' + 'Enforced mode: re-run Remove-OrphanedResource with -Apply AND -ConfirmationToken , after user confirmation.' } else { - 'Re-run remediate_delete_orphaned_resource with apply=true (after user confirmation) to delete this resource.' + 'Re-run Remove-OrphanedResource with -Apply (after user confirmation) to delete this resource.' } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 index b7b31e68e..073f293cf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 @@ -125,7 +125,7 @@ function Stop-IdleVm { 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.' } + else { 'Re-run Stop-IdleVm with -Apply (after user confirmation) to deallocate this VM.' } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 index f4b90d576..76e5870cc 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 @@ -16,7 +16,9 @@ function Get-PlainAccessToken { $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token if ($tok -is [securestring]) { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok) - try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) } + # PtrToStringBSTR, not PtrToStringAuto: a BSTR is always UTF-16, but Auto + # picks the platform default and truncates the token to one char on macOS. + try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } else { $tok } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index 80d3ddc51..b3690d85c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -315,6 +315,24 @@ function Read-ParquetFile { } } +# Resolve a row's subscription GUID. FOCUS exports use SubAccountId, older +# exports use SubscriptionId or x_SubscriptionId, and any of them may hold a +# full resource path rather than a bare GUID. +function Get-FinOpsHubRowSubscriptionId { + param([object]$Row) + + $props = $Row.PSObject.Properties.Name + $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 { '' } + + if ($subId -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { + return $Matches[0].ToLower() + } + return ([string]$subId).ToLower() +} + function Read-FinOpsHubData { [CmdletBinding()] param( @@ -325,7 +343,13 @@ function Read-FinOpsHubData { [string]$ResourceGroupName, [Parameter()] - [int]$Months = 1 + [int]$Months = 1, + + # Restrict returned rows to these subscriptions. A hub holds every + # subscription it ingests, so without this a scoped scan reports on + # subscriptions the user did not select. + [Parameter()] + [string[]]$SubscriptionIds ) Write-Host " Connecting to Hub storage: $StorageAccountName" -ForegroundColor DarkGray @@ -466,6 +490,14 @@ function Read-FinOpsHubData { Write-Host " Total rows from Hub ($source): $($allData.Count)" -ForegroundColor Green } + if ($SubscriptionIds -and $SubscriptionIds.Count -gt 0 -and $allData.Count -gt 0) { + $wanted = @{} + foreach ($s in $SubscriptionIds) { if ($s) { $wanted[$s.ToLower()] = $true } } + $before = $allData.Count + $allData = @($allData | Where-Object { $wanted.ContainsKey((Get-FinOpsHubRowSubscriptionId $_)) }) + Write-Host " Scoped to $($SubscriptionIds.Count) selected subscription(s): $($allData.Count) of $before rows" -ForegroundColor DarkGray + } + return $allData } diff --git a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 index 7d9ccb96f..065ef6bc8 100644 --- a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 +++ b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 @@ -3,16 +3,18 @@ & "$PSScriptRoot/../Initialize-Tests.ps1" -BeforeAll { - $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' - Import-Module $script:MultitoolModule -Force -} +Describe 'FinOps Hub Kusto provider' { -AfterAll { - Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue -} + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } -Describe 'FinOps Hub Kusto provider' { + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } Context 'Resolve-FOHubProvider - explicit override' { AfterEach { From 69add06fcc44e8c2bea1f112e5cb01f3733a1735 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 12:59:16 -0600 Subject: [PATCH 77/79] fix(multitool): correct realized savings math (review finding 7) Savings was computed as amortized committed cost multiplied by a flat discount percentage, which measures a share of what was paid rather than the gap up to pay-as-you-go. $100 paid at a 40% discount implies $66.67 saved, not $40. Savings is now paid * d / (1 - d). The assumed rates are named constants with the derivation documented alongside them. Real discounts vary by SKU, term, region, and agreement, so the RI and savings plan figures remain an estimate. The result now carries IsEstimate and EstimateBasis, the module header no longer claims measured savings, and the terminal UI prints the basis under the breakdown. --- .../Invoke-FinOpsMultitool.ps1 | 5 +++- .../modules/Get-SavingsRealized.ps1 | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 93991ef0e..81cf032a2 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1203,9 +1203,12 @@ function Invoke-FinOpsMultitool { } } 'Get-SavingsRealized' { - Write-Host " Monthly savings breakdown:" -ForegroundColor White + Write-Host " Estimated 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' + if ($data.EstimateBasis) { + Write-Host " $($data.EstimateBasis)" -ForegroundColor DarkGray + } $rows = $null # summary only } 'Get-CostData' { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index ee9482330..ca0732fc8 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -3,11 +3,12 @@ ########################################################################### # GET-SAVINGSREALIZED.PS1 -# AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments +# AZURE FINOPS MULTITOOL - Estimated Savings 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. +# Purpose: Estimate how much existing RIs, Savings Plans, and AHB are saving +# versus pay-as-you-go. RI and savings plan figures apply an assumed +# effective discount rate, so they are an estimate rather than +# measured savings - see EstimateBasis on the result. ########################################################################### function Get-SavingsRealized { @@ -28,6 +29,19 @@ function Get-SavingsRealized { $riSavings = 0 $spSavings = 0 $ahbSavings = 0 + + # Assumed effective discount versus pay-as-you-go. Real discounts vary by + # SKU, term, region, and agreement, so the RI/SP numbers below are an + # estimate rather than measured savings. + $riDiscountRate = 0.40 + $spDiscountRate = 0.25 + + # Savings is the gap up to the PAYG price, not a share of what was paid: + # payg = paid / (1 - d) + # savings = payg - paid = paid * d / (1 - d) + # At a 40% discount, $100 paid implies $66.67 saved, not $40. + $script:FinOpsRiSavingsFactor = $riDiscountRate / (1 - $riDiscountRate) + $script:FinOpsSpSavingsFactor = $spDiscountRate / (1 - $spDiscountRate) # 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 @@ -124,12 +138,12 @@ function Get-SavingsRealized { $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } } if ($pm -match 'Reservation') { - $ri += $cost * 0.4 + $ri += $cost * $script:FinOpsRiSavingsFactor $committed += $cost $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment' }) } elseif ($pm -match 'SavingsPlan') { - $sp += $cost * 0.25 + $sp += $cost * $script:FinOpsSpSavingsFactor $committed += $cost $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment' }) } @@ -370,6 +384,8 @@ resources SpotAmortized = [math]::Round($spotAmort, 2) CommitmentCoveragePct = $commitmentCoverage Details = @($details) + IsEstimate = $true + EstimateBasis = "RI and savings plan figures assume a $([int]($riDiscountRate * 100))% and $([int]($spDiscountRate * 100))% effective discount versus pay-as-you-go. Actual discounts vary by SKU, term, region, and agreement. Compare against matching PAYG retail rates for measured savings." HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0) } } From 4974f361fc6c43f7939ea1c2776c08fe77de9d78 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 13:12:10 -0600 Subject: [PATCH 78/79] fix(multitool): resolve explicit subscription scope before the pickers (review finding 8) A supplied -SubscriptionId was resolved only after the tenant picker had already run, and the lookup omitted -TenantId. Get-AzSubscription without a tenant probes every accessible tenant, so a scoped run emitted repeated conditional-access/MFA warnings for tenants that refuse. Resolution now happens immediately after the connection check: the current context tenant is tried first, falling back to an explicit per-tenant lookup only if that misses. On success the context is set to that subscription and tenant and both pickers are bypassed, which is what an explicitly scoped invocation should do. Also picks up a formatter pass that split "} catch {" across lines in Get-SavingsRealized.ps1 to match the surrounding style. --- .../Invoke-FinOpsMultitool.ps1 | 36 ++- .../modules/Get-SavingsRealized.ps1 | 264 +++++++++--------- 2 files changed, 161 insertions(+), 139 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 81cf032a2..d1203c438 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -316,6 +316,33 @@ function Invoke-FinOpsMultitool { Write-Host " Signed in as: $($ctx.Account.Id)" -ForegroundColor Green Write-Host "" + # -- Explicit scope: resolve before either picker ------------------ + # When the caller already named a subscription there is nothing to pick, + # so resolve it, set that context, and return. The current tenant is + # tried first because Get-AzSubscription without -TenantId probes every + # accessible tenant and emits a conditional-access/MFA warning for each + # one that refuses. + if ($PreselectedId) { + $sub = $null + if ($ctx -and $ctx.Tenant -and $ctx.Tenant.Id) { + $sub = Get-AzSubscription -SubscriptionId $PreselectedId -TenantId $ctx.Tenant.Id -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + if (-not $sub) { + foreach ($t in @(Get-AzTenant -ErrorAction SilentlyContinue)) { + $sub = Get-AzSubscription -SubscriptionId $PreselectedId -TenantId $t.Id -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + if ($sub) { break } + } + } + if ($sub) { + $null = Set-AzContext -SubscriptionId $sub.Id -TenantId $sub.TenantId -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + Write-Host " Using subscription: $($sub.Name)" -ForegroundColor Green + Write-Host " Tenant: $($sub.TenantId)" -ForegroundColor Green + Write-Host "" + return @($sub) + } + Write-Host " Subscription $PreselectedId not found in any accessible tenant, showing picker..." -ForegroundColor Yellow + } + # -- Tenant picker ------------------------------------------------ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue) if ($tenants.Count -gt 1) { @@ -386,15 +413,6 @@ function Invoke-FinOpsMultitool { 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 diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index ca0732fc8..e81e6cef5 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -26,8 +26,8 @@ function Get-SavingsRealized { Write-Host " Calculating savings already realized..." -ForegroundColor Cyan - $riSavings = 0 - $spSavings = 0 + $riSavings = 0 + $spSavings = 0 $ahbSavings = 0 # Assumed effective discount versus pay-as-you-go. Real discounts vary by @@ -47,9 +47,9 @@ function Get-SavingsRealized { # 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() + $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 @@ -61,7 +61,7 @@ function Get-SavingsRealized { } $gotMgData = $false - $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } + $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } # Map subscription Id -> friendly name so MG-grouped rows keep per-sub attribution $subNameById = @{} @@ -87,9 +87,9 @@ function Get-SavingsRealized { $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 } + 'Cost' { $map.Cost = $c } + 'ChargeType' { $map.ChargeType = $c } + 'PricingModel' { $map.PricingModel = $c } 'SubscriptionId' { $map.SubscriptionId = $c } } } @@ -111,11 +111,11 @@ function Get-SavingsRealized { $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' - }) + Subscription = $sub + Category = 'Unused Reservation' + Amount = [math]::Round([double]$row[$m.Cost], 2) + Type = 'Waste' + }) } } return $rows @@ -130,9 +130,9 @@ function Get-SavingsRealized { 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 { '' } + $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' } $cost = [math]::Round([double]$row[$m.Cost], 2) - $sub = 'All (MG scope)' + $sub = 'All (MG scope)' if ($m.SubscriptionId -ge 0) { $sid = [string]$row[$m.SubscriptionId] $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } @@ -175,13 +175,14 @@ function Get-SavingsRealized { $riSavings += $parsed.RI $spSavings += $parsed.SP $committedAmort += $parsed.Committed - $onDemandAmort += $parsed.OnDemand - $spotAmort += $parsed.Spot + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot } $gotMgData = $true Write-Host " Single-subscription savings calculated (2 API calls)" -ForegroundColor Green - } catch { + } + catch { Write-Warning " Single-subscription savings query failed: $($_.Exception.Message)" } } @@ -209,13 +210,14 @@ function Get-SavingsRealized { $riSavings += $parsed.RI $spSavings += $parsed.SP $committedAmort += $parsed.Committed - $onDemandAmort += $parsed.OnDemand - $spotAmort += $parsed.Spot + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot } $gotMgData = $true Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green - } catch { + } + catch { Write-Warning " MG-scope savings query failed: $($_.Exception.Message)" } } @@ -223,110 +225,111 @@ function Get-SavingsRealized { # -- 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)..." + # -- 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' } + try { + # Get ActualCost MonthToDate + $actualBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ChargeType' } + ) } - grouping = @( - @{ type = 'Dimension'; name = 'ChargeType' } - ) - } - } | ConvertTo-Json -Depth 10 + } | 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 + $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' - }) + 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' } + # 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' } + ) } - grouping = @( - @{ type = 'Dimension'; name = 'PricingModel' } - ) - } - } | ConvertTo-Json -Depth 10 + } | 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' - }) + $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 } } - elseif ($pricingModel -match 'Spot') { $spotAmort += $cost } - elseif ($pricingModel) { $onDemandAmort += $cost } } } } - } catch { - Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)" + 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) --- @@ -352,18 +355,19 @@ resources $ahbSavings += $perVm } [void]$details.Add([PSCustomObject]@{ - Subscription = 'All' - Category = 'Azure Hybrid Benefit (VMs)' - Amount = [math]::Round($ahbSavings, 2) - Type = 'AHB' - }) + Subscription = 'All' + Category = 'Azure Hybrid Benefit (VMs)' + Amount = [math]::Round($ahbSavings, 2) + Type = 'AHB' + }) } - } catch { + } + catch { Write-Warning " AHB savings query failed: $($_.Exception.Message)" } $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2) - $totalAnnual = [math]::Round($totalMonthly * 12, 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). @@ -374,18 +378,18 @@ resources 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) + 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) - IsEstimate = $true - EstimateBasis = "RI and savings plan figures assume a $([int]($riDiscountRate * 100))% and $([int]($spDiscountRate * 100))% effective discount versus pay-as-you-go. Actual discounts vary by SKU, term, region, and agreement. Compare against matching PAYG retail rates for measured savings." - HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0) + Details = @($details) + IsEstimate = $true + EstimateBasis = "RI and savings plan figures assume a $([int]($riDiscountRate * 100))% and $([int]($spDiscountRate * 100))% effective discount versus pay-as-you-go. Actual discounts vary by SKU, term, region, and agreement. Compare against matching PAYG retail rates for measured savings." + HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0) } } From 08e945a0d41338f6e419f18958f2cbb8e49340c2 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 13:18:32 -0600 Subject: [PATCH 79/79] fix(multitool): project scan results to flat rows before CSV export (review finding 5) Exports piped the raw scan wrapper straight to Export-Csv, so collection and hashtable properties landed as "System.Object[]" and "System.Collections.Hashtable" cells, and the cost summary - a hashtable keyed by subscription - turned every subscription id into a column. Adds ConvertTo-FinOpsExportRows, which projects a result to flat rows: explicit projections for the cost summary and cost-by-tag contracts, a key/value shape for other dictionaries, and for wrapper objects the single collection property (falling back to a preferred name when several exist) so scans that follow the existing pattern export correctly without a case of their own. Summary-only contracts export their scalar properties as one row. Every projection then passes through ConvertTo-FinOpsExportCell, which guarantees scalar cells regardless of contract - dictionaries and arrays are rendered as delimited text rather than type names. Verified against a wrapper-plus-array result, a subscription-keyed hashtable, a nested tag map, and a summary-only object: no object-type cells in any. --- .../Invoke-FinOpsMultitool.ps1 | 108 +++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index d1203c438..ca936427f 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1042,6 +1042,108 @@ function Invoke-FinOpsMultitool { } } + # CSV cells must be scalars. Anything else lands as "System.Collections.Hashtable" + # or "System.Object[]" in the file. + function ConvertTo-FinOpsExportCell { + param($Value) + + if ($null -eq $Value) { return '' } + if ($Value -is [string] -or $Value -is [ValueType]) { return $Value } + if ($Value -is [System.Collections.IDictionary]) { + return (($Value.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ') + } + if ($Value -is [System.Collections.IEnumerable]) { + return ((@($Value) | ForEach-Object { [string]$_ }) -join '; ') + } + return [string]$Value + } + + # Scan results are wrapper objects whose payload is a nested collection or a + # hashtable keyed by subscription. Exporting the wrapper directly produces + # object-type cells, and for the cost contracts it turns subscription IDs + # into columns. Project each result to flat rows before writing CSV. + function ConvertTo-FinOpsExportRows { + param( + [string]$Fn, + $Data + ) + + if ($null -eq $Data) { return @() } + + $rows = $null + + # Contracts whose payload is not a plain collection. + if ($Fn -eq 'Get-CostData' -and $Data -is [System.Collections.IDictionary]) { + $rows = @($Data.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ + SubscriptionId = $_.Key + Actual = $_.Value.Actual + Forecast = $_.Value.Forecast + Currency = $_.Value.Currency + } + }) + } + elseif ($Fn -eq 'Get-CostByTag' -and $Data.CostByTag) { + $rows = @(foreach ($tag in $Data.CostByTag.GetEnumerator()) { + foreach ($val in $tag.Value.GetEnumerator()) { + [PSCustomObject]@{ + TagKey = $tag.Key + TagValue = $val.Key + Cost = $val.Value + } + } + }) + } + elseif ($Data -is [System.Collections.IDictionary]) { + $rows = @($Data.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ Key = $_.Key; Value = (ConvertTo-FinOpsExportCell $_.Value) } + }) + } + elseif ($Data -is [System.Collections.IEnumerable] -and $Data -isnot [string]) { + $rows = @($Data) + } + else { + # Wrapper object: the payload is the collection property. Prefer the + # single collection when there is exactly one, so new scans that follow + # the pattern export correctly without needing a case here. + $collections = @($Data.PSObject.Properties | Where-Object { + $_.Value -is [System.Collections.IEnumerable] -and + $_.Value -isnot [string] -and + $_.Value -isnot [System.Collections.IDictionary] -and + @($_.Value).Count -gt 0 + }) + if ($collections.Count -eq 1) { + $rows = @($collections[0].Value) + } + elseif ($collections.Count -gt 1) { + $preferred = $collections | Where-Object { $_.Name -in @('Rows', 'Details', 'Analysis', 'Recommendations', 'Items') } | Select-Object -First 1 + $rows = if ($preferred) { @($preferred.Value) } else { @($collections[0].Value) } + } + else { + # Summary-only contract: one row of its scalar properties. + $rows = @($Data) + } + } + + # Whatever projection was chosen, guarantee scalar cells. + return @($rows | Where-Object { $null -ne $_ } | ForEach-Object { + $row = $_ + if ($row -is [System.Collections.IDictionary]) { + $ordered = [ordered]@{} + foreach ($k in $row.Keys) { $ordered[[string]$k] = ConvertTo-FinOpsExportCell $row[$k] } + [PSCustomObject]$ordered + } + elseif ($row.PSObject.Properties.Count -gt 0 -and $row -isnot [string] -and $row -isnot [ValueType]) { + $ordered = [ordered]@{} + foreach ($p in $row.PSObject.Properties) { $ordered[$p.Name] = ConvertTo-FinOpsExportCell $p.Value } + [PSCustomObject]$ordered + } + else { + [PSCustomObject]@{ Value = ConvertTo-FinOpsExportCell $row } + } + }) + } + function Show-ResultsSummary { param( [hashtable]$Results, @@ -2146,10 +2248,12 @@ function Invoke-FinOpsMultitool { # -- CSV exports per module -- foreach ($mod in ($Modules | Where-Object { $_.Selected })) { $data = $Results[$mod.Fn] - if ($data -and @($data).Count -gt 0) { + if (-not $data) { continue } + $exportRows = ConvertTo-FinOpsExportRows -Fn $mod.Fn -Data $data + if ($exportRows.Count -gt 0) { $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', '' $csvPath = Join-Path $exportDir "$safeName.csv" - $data | Export-Csv -Path $csvPath -NoTypeInformation + $exportRows | Export-Csv -Path $csvPath -NoTypeInformation } }